Compare commits

..

2 Commits

Author SHA1 Message Date
gnanam1990
91dea452be fix(shell): drop now-unused realpath import 2026-04-24 07:43:43 +05:30
gnanam1990
0e620ae9ea 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)
2026-04-24 07:38:46 +05:30
2 changed files with 40 additions and 38 deletions

View File

@@ -35,20 +35,15 @@ export async function update() {
// binary (without it).
if (getAPIProvider() !== 'firstParty') {
writeToStdout(
chalk.yellow(
`Auto-update is not available for third-party provider builds.\n`,
) +
`Current version: ${MACRO.DISPLAY_VERSION}\n\n` +
`To update, reinstall from npm:\n` +
chalk.bold(` npm install -g ${MACRO.PACKAGE_URL}@latest`) + '\n\n' +
`Or, if you built from source, pull and rebuild:\n` +
chalk.bold(' git pull && bun install && bun run build') + '\n',
chalk.yellow('Auto-update is not available for third-party provider builds.\n') +
'To update, pull the latest source from the repository and rebuild:\n' +
' git pull && bun install && bun run build\n',
)
await gracefulShutdown(0)
return
}
logEvent('tengu_update_check', {})
writeToStdout(`Current version: ${MACRO.DISPLAY_VERSION}\n`)
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
writeToStdout(`Checking for updates to ${channel} version...\n`)
@@ -128,14 +123,9 @@ export async function update() {
if (diagnostic.installationType === 'development') {
writeToStdout('\n')
writeToStdout(
chalk.yellow('You are running a development build — auto-update is unavailable.') + '\n',
chalk.yellow('Warning: Cannot update development build') + '\n',
)
writeToStdout('To update, pull the latest source and rebuild:\n')
writeToStdout(chalk.bold(' git pull && bun install && bun run build') + '\n')
writeToStdout('\n')
writeToStdout('Or reinstall from npm:\n')
writeToStdout(chalk.bold(` npm install -g ${MACRO.PACKAGE_URL}@latest`) + '\n')
await gracefulShutdown(0)
await gracefulShutdown(1)
}
// Check if running from a package manager
@@ -146,8 +136,8 @@ export async function update() {
if (packageManager === 'homebrew') {
writeToStdout('Claude is managed by Homebrew.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION}${latest}\n`)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
@@ -157,8 +147,8 @@ export async function update() {
} else if (packageManager === 'winget') {
writeToStdout('Claude is managed by winget.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION}${latest}\n`)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(
@@ -170,8 +160,8 @@ export async function update() {
} else if (packageManager === 'apk') {
writeToStdout('Claude is managed by apk.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.DISPLAY_VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.DISPLAY_VERSION}${latest}\n`)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
@@ -260,14 +250,14 @@ export async function update() {
await gracefulShutdown(1)
}
if (result.latestVersion === MACRO.DISPLAY_VERSION) {
if (result.latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`OpenClaude is up to date (${MACRO.DISPLAY_VERSION})`) + '\n',
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
)
} else {
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.DISPLAY_VERSION} to version ${result.latestVersion}`,
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
@@ -330,15 +320,15 @@ export async function update() {
}
// Check if versions match exactly, including any build metadata (like SHA)
if (latestVersion === MACRO.DISPLAY_VERSION) {
if (latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`OpenClaude is up to date (${MACRO.DISPLAY_VERSION})`) + '\n',
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
)
await gracefulShutdown(0)
}
writeToStdout(
`New version available: ${latestVersion} (current: ${MACRO.DISPLAY_VERSION})\n`,
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
)
writeToStdout('Installing update...\n')
@@ -398,7 +388,7 @@ export async function update() {
case 'success':
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.DISPLAY_VERSION} to version ${latestVersion}`,
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()

View File

@@ -1,6 +1,6 @@
import { execFileSync, spawn } from 'child_process'
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 { isAbsolute, resolve } from 'path'
import { join as posixJoin } from 'path/posix'
@@ -217,22 +217,34 @@ export async function exec(
let cwd = pwd()
// 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).
// Recover if the current working directory no longer exists on disk,
// 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 {
await realpath(cwd)
cwdIsValidDir = (await stat(cwd)).isDirectory()
} catch {
cwdIsValidDir = false
}
if (!cwdIsValidDir) {
const fallback = getOriginalCwd()
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 {
await realpath(fallback)
fallbackIsValidDir = (await stat(fallback)).isDirectory()
} catch {
fallbackIsValidDir = false
}
if (fallbackIsValidDir) {
setCwdState(fallback)
cwd = fallback
} catch {
} else {
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.`,
)
}
}