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",