From b98d07a622b334adc0fc50149340add40c1377f3 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 27 Sep 2026 20:16:16 +0530 Subject: [PATCH] fix(ai): silence the auto-update error from the bundled Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled Claude Code ships inside Phoenix and is replaced when Phoenix updates, so its own auto-updater has nothing useful to do — and it cannot do it anyway: it resolves the npm prefix of whatever install it thinks it is and tries to write there. It failed on every launch and said so in red across the CLI pane ("Auto-update failed: no write permission to npm prefix"). Set DISABLE_AUTOUPDATER for it, the supported way out (Anthropic set the same var for their own managed runs). Keyed on the locator's `source`, so a CLI the user installed keeps its updater: that copy is theirs to keep current, and it is how their version eventually overtakes the bundled one. Both spawn paths carry it — the CLI pane's PTY and the SDK query, which runs the same executable. The SDK path now resolves through locateCli directly for the source, retiring the findGlobalClaudeCli wrapper. Also includes the `userInstalled` field on locate results, reporting that the user has a Claude Code of their own even when the newer bundled copy is the one that runs. --- src-node/claude-code-agent.js | 29 +++++++++-------------------- src-node/cli-locator.js | 29 ++++++++++++++++++++++++++--- src-node/test/test-cli-locator.js | 6 +++--- test/spec/CLILocator-test.js | 17 +++++++++++++++++ 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index cb96f66fd9..23e7ef827e 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -604,20 +604,6 @@ async function _getAISessionTitle(sessionId, projectPath) { } } -/** - * Resolve the user's globally installed Claude CLI, honouring the path - * override configured in AI Settings. Kept as a local function so the SDK - * path below reads the same as it always has; the search itself now lives - * in cli-locator.js, shared with the other CLIs the panel can drive. - * Pass `{ force: true }` to invalidate the cache after a spawn failure. - * @return {Promise} absolute path, or null when not found - */ -function findGlobalClaudeCli(opts) { - return CliLocator.locateCli("claude", opts).then(function (result) { - return result.path; - }); -} - // Brand names for the messages below. Not translatable and never shown // raw — the browser maps errorCode to a localized string; these only reach // logs and metrics. @@ -826,7 +812,7 @@ exports.getCliSpawnProfile = async function (params) { if (!result.available) { return Object.assign({}, result, { command: null, args: [] }); } - const profile = CliLocator.getSpawnProfile(result.path); + const profile = CliLocator.getSpawnProfile(result.path, result.source); return Object.assign({}, result, profile); }; @@ -2118,7 +2104,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // It also carries the live preview nudge as a fallback for // Claude CLI versions that predate PostToolBatch: the batch // hook below is the primary path, but we run the user's - // global CLI (findGlobalClaudeCli) so we can't assume it. + // whichever CLI the locator picked, so we can't assume it. // Whichever fires first takes the hint; the other sees a // cleared counter. hooks: [ @@ -2214,10 +2200,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, }; } - // Set Claude CLI path if found - const claudePath = await findGlobalClaudeCli(); - if (claudePath) { - queryOptions.pathToClaudeCodeExecutable = claudePath; + // Set Claude CLI path if found. The env the binary's origin calls for + // rides along: the SDK spawns the same executable the CLI pane does, so + // the bundled copy must not try to auto-update here either. + const located = await CliLocator.locateCli("claude"); + if (located.path) { + queryOptions.pathToClaudeCodeExecutable = located.path; + Object.assign(queryOptions.env, CliLocator.getSourceEnv(located.source)); } if (model) { diff --git a/src-node/cli-locator.js b/src-node/cli-locator.js index a1b1fe19e7..088912d1f5 100644 --- a/src-node/cli-locator.js +++ b/src-node/cli-locator.js @@ -780,6 +780,26 @@ function findDownloader() { return null; } +/** + * Env a CLI needs on top of PATH, decided by where the binary came from. + * + * The bundled Claude Code ships inside Phoenix and is replaced when Phoenix + * updates, so its own auto-updater has nothing useful to do — and it cannot + * do it anyway: it resolves the npm prefix of whatever install it thinks it + * is and tries to write there, which is a directory Phoenix neither owns nor + * should touch. It fails every launch and says so in red across the CLI pane + * ("Auto-update failed: no write permission to npm prefix"). Turning the + * updater off is the supported way out (Anthropic set the same var for their + * own managed runs). A CLI the *user* installed keeps its updater: that copy + * is theirs to keep current, and it is how their version eventually + * overtakes the bundled one. + * @param {?string} source - `source` from locateCli(): "bundled" when ours + * @return {Object} + */ +function _cliSourceEnv(source) { + return source === "bundled" ? { DISABLE_AUTOUPDATER: "1" } : {}; +} + /** * How to hand a resolved binary to node-pty. * @@ -789,12 +809,14 @@ function findDownloader() { * to spawn, so every terminal caller must resolve through here rather than * using the raw path. * `env` carries the same PATH used for probes so child tools such as npm - * remain available in the terminal. Callers must forward it to the PTY. + * remain available in the terminal, plus whatever `source` implies. Callers + * must forward it to the PTY. * @param {string} cliPath - Resolved CLI path + * @param {?string} [source] - `source` from locateCli(), for the env above * @return {{command: string, args: Array, env: Object}} */ -function getSpawnProfile(cliPath) { - const env = _cliPathEnv(cliPath, process.env); +function getSpawnProfile(cliPath, source) { + const env = Object.assign(_cliPathEnv(cliPath, process.env), _cliSourceEnv(source)); if (isWindows && /\.(cmd|bat)$/i.test(cliPath || "")) { return { command: process.env.COMSPEC || "cmd.exe", args: ["/c", cliPath], env }; } @@ -809,6 +831,7 @@ exports.locateCli = locateCli; exports.validateCliPath = validateCliPath; exports.findDownloader = findDownloader; exports.getSpawnProfile = getSpawnProfile; +exports.getSourceEnv = _cliSourceEnv; exports.compareVersions = compareVersions; exports.setBundledRoot = setBundledRoot; exports.setOverrides = setOverrides; diff --git a/src-node/test/test-cli-locator.js b/src-node/test/test-cli-locator.js index ec46cc0eb5..1dd8a4a3d9 100644 --- a/src-node/test/test-cli-locator.js +++ b/src-node/test/test-cli-locator.js @@ -73,12 +73,12 @@ function _runProcess(command, args, env) { /** * Return a launch profile and the input environment after the locator runs. - * @param {Object} params - Simulated platform, env, and cliPath + * @param {Object} params - Simulated platform, env, cliPath, and locate source * @return {Promise} Profile and environment for Jasmine assertions */ -async function getSpawnProfile({platform, env, cliPath}) { +async function getSpawnProfile({platform, env, cliPath, source}) { const locator = _loadLocator(platform, env); - return { profile: locator.getSpawnProfile(cliPath), env }; + return { profile: locator.getSpawnProfile(cliPath, source), env }; } /** diff --git a/test/spec/CLILocator-test.js b/test/spec/CLILocator-test.js index 7068b66aca..9b3aea2525 100644 --- a/test/spec/CLILocator-test.js +++ b/test/spec/CLILocator-test.js @@ -35,6 +35,23 @@ define(function (require, exports, module) { }); }); + it("should silence the auto-updater only for the bundled Claude Code", async function () { + const args = { + platform: "linux", env: { PATH: "/usr/bin" }, cliPath: "/opt/node/bin/claude" + }; + // Ours: it cannot write to the npm prefix it thinks it owns, and + // says so in red on every launch. Phoenix updates this copy. + const bundled = await nodeConnector.execPeer("getSpawnProfile", + Object.assign({ source: "bundled" }, args)); + expect(bundled.profile.env.DISABLE_AUTOUPDATER).toBe("1"); + // Theirs: left alone, so their own install can still catch up. + for (const source of ["native", "fallback", "override", null]) { + const own = await nodeConnector.execPeer("getSpawnProfile", + Object.assign({ source }, args)); + expect(Object.keys(own.profile.env)).withContext(source).toEqual(["PATH"]); + } + }); + it("macOS: should make Homebrew bin available to the updater", async function () { const result = await nodeConnector.execPeer("getSpawnProfile", { platform: "darwin",