fix(shell): recover when CWD path was replaced by a non-directory (#871)

* fix(shell): recover when CWD path was replaced by a non-directory

Closes #844.

When the session's cached working directory is renamed on disk and
a file is subsequently created at the old path (e.g. `mv orig renamed
&& touch orig`), every Bash tool invocation failed with
`ENOTDIR: not a directory, posix_spawn '/usr/bin/zsh'` (exit 126),
and `!`-prefixed commands silently failed. No recovery was possible
without restarting the session.

Root cause: the pre-spawn guard in `src/utils/Shell.ts:exec()` used
`realpath(cwd)` to detect a missing CWD. `realpath()` succeeds on
any existing path — file or directory — so a path that was replaced
with a regular file slipped past the check. spawn() was then called
with `cwd` pointing at a non-directory and failed with ENOTDIR.

Fix: replace `realpath()` with `stat().isDirectory()` for both the
primary CWD check and the `getOriginalCwd()` fallback check. When
the cached CWD is no longer a directory, fall back to the original
CWD (as before) and update state so subsequent tools recover
transparently.

Verification:
  - Repro: `mkdir -p /tmp/x/orig && mv /tmp/x/orig /tmp/x/renamed
    && touch /tmp/x/orig`, then exec with stale cwd=/tmp/x/orig
  - Before: exit 126, stderr "ENOTDIR: not a directory, posix_spawn"
  - After:  exit 0, cwd transparently recovered to originalCwd
  - `bun test` — no new regressions (pre-existing model/provider
    test failures are unrelated and present on main)

* fix(shell): drop now-unused realpath import
This commit is contained in:
KRATOS
2026-04-24 09:04:08 +05:30
committed by GitHub
parent 6e58b81937
commit a4c6757023

View File

@@ -1,6 +1,6 @@
import { execFileSync, spawn } from 'child_process' import { execFileSync, spawn } from 'child_process'
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs' import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
import { type FileHandle, mkdir, open, realpath } from 'fs/promises' import { type FileHandle, mkdir, open, stat } from 'fs/promises'
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { isAbsolute, resolve } from 'path' import { isAbsolute, resolve } from 'path'
import { join as posixJoin } from 'path/posix' import { join as posixJoin } from 'path/posix'
@@ -217,22 +217,34 @@ export async function exec(
let cwd = pwd() let cwd = pwd()
// Recover if the current working directory no longer exists on disk. // Recover if the current working directory no longer exists on disk,
// This can happen when a command deletes its own CWD (e.g., temp dir cleanup). // or was replaced by a non-directory (e.g., the path was renamed and a file
// was created in its place). realpath() succeeds on any existing path
// regardless of type, so we must also verify it's a directory — otherwise
// spawn would fail later with ENOTDIR / exit 126.
let cwdIsValidDir = false
try { try {
await realpath(cwd) cwdIsValidDir = (await stat(cwd)).isDirectory()
} catch { } catch {
cwdIsValidDir = false
}
if (!cwdIsValidDir) {
const fallback = getOriginalCwd() const fallback = getOriginalCwd()
logForDebugging( logForDebugging(
`Shell CWD "${cwd}" no longer exists, recovering to "${fallback}"`, `Shell CWD "${cwd}" is not a valid directory, recovering to "${fallback}"`,
) )
let fallbackIsValidDir = false
try { try {
await realpath(fallback) fallbackIsValidDir = (await stat(fallback)).isDirectory()
} catch {
fallbackIsValidDir = false
}
if (fallbackIsValidDir) {
setCwdState(fallback) setCwdState(fallback)
cwd = fallback cwd = fallback
} catch { } else {
return createFailedCommand( return createFailedCommand(
`Working directory "${cwd}" no longer exists. Please restart Claude from an existing directory.`, `Working directory "${cwd}" is no longer a valid directory. Please restart Claude from an existing directory.`,
) )
} }
} }