Skip to content

Commit 670b9c7

Browse files
committed
fix(ai): silence the auto-update error from the bundled Claude Code
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.
1 parent d37d4fa commit 670b9c7

4 files changed

Lines changed: 55 additions & 26 deletions

File tree

‎src-node/claude-code-agent.js‎

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -604,20 +604,6 @@ async function _getAISessionTitle(sessionId, projectPath) {
604604
}
605605
}
606606

607-
/**
608-
* Resolve the user's globally installed Claude CLI, honouring the path
609-
* override configured in AI Settings. Kept as a local function so the SDK
610-
* path below reads the same as it always has; the search itself now lives
611-
* in cli-locator.js, shared with the other CLIs the panel can drive.
612-
* Pass `{ force: true }` to invalidate the cache after a spawn failure.
613-
* @return {Promise<string|null>} absolute path, or null when not found
614-
*/
615-
function findGlobalClaudeCli(opts) {
616-
return CliLocator.locateCli("claude", opts).then(function (result) {
617-
return result.path;
618-
});
619-
}
620-
621607
// Brand names for the messages below. Not translatable and never shown
622608
// raw — the browser maps errorCode to a localized string; these only reach
623609
// logs and metrics.
@@ -826,7 +812,7 @@ exports.getCliSpawnProfile = async function (params) {
826812
if (!result.available) {
827813
return Object.assign({}, result, { command: null, args: [] });
828814
}
829-
const profile = CliLocator.getSpawnProfile(result.path);
815+
const profile = CliLocator.getSpawnProfile(result.path, result.source);
830816
return Object.assign({}, result, profile);
831817
};
832818

@@ -2118,7 +2104,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
21182104
// It also carries the live preview nudge as a fallback for
21192105
// Claude CLI versions that predate PostToolBatch: the batch
21202106
// hook below is the primary path, but we run the user's
2121-
// global CLI (findGlobalClaudeCli) so we can't assume it.
2107+
// whichever CLI the locator picked, so we can't assume it.
21222108
// Whichever fires first takes the hint; the other sees a
21232109
// cleared counter.
21242110
hooks: [
@@ -2214,10 +2200,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
22142200
};
22152201
}
22162202

2217-
// Set Claude CLI path if found
2218-
const claudePath = await findGlobalClaudeCli();
2219-
if (claudePath) {
2220-
queryOptions.pathToClaudeCodeExecutable = claudePath;
2203+
// Set Claude CLI path if found. The env the binary's origin calls for
2204+
// rides along: the SDK spawns the same executable the CLI pane does, so
2205+
// the bundled copy must not try to auto-update here either.
2206+
const located = await CliLocator.locateCli("claude");
2207+
if (located.path) {
2208+
queryOptions.pathToClaudeCodeExecutable = located.path;
2209+
Object.assign(queryOptions.env, CliLocator.getSourceEnv(located.source));
22212210
}
22222211

22232212
if (model) {

‎src-node/cli-locator.js‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,26 @@ function findDownloader() {
780780
return null;
781781
}
782782

783+
/**
784+
* Env a CLI needs on top of PATH, decided by where the binary came from.
785+
*
786+
* The bundled Claude Code ships inside Phoenix and is replaced when Phoenix
787+
* updates, so its own auto-updater has nothing useful to do — and it cannot
788+
* do it anyway: it resolves the npm prefix of whatever install it thinks it
789+
* is and tries to write there, which is a directory Phoenix neither owns nor
790+
* should touch. It fails every launch and says so in red across the CLI pane
791+
* ("Auto-update failed: no write permission to npm prefix"). Turning the
792+
* updater off is the supported way out (Anthropic set the same var for their
793+
* own managed runs). A CLI the *user* installed keeps its updater: that copy
794+
* is theirs to keep current, and it is how their version eventually
795+
* overtakes the bundled one.
796+
* @param {?string} source - `source` from locateCli(): "bundled" when ours
797+
* @return {Object}
798+
*/
799+
function _cliSourceEnv(source) {
800+
return source === "bundled" ? { DISABLE_AUTOUPDATER: "1" } : {};
801+
}
802+
783803
/**
784804
* How to hand a resolved binary to node-pty.
785805
*
@@ -789,12 +809,14 @@ function findDownloader() {
789809
* to spawn, so every terminal caller must resolve through here rather than
790810
* using the raw path.
791811
* `env` carries the same PATH used for probes so child tools such as npm
792-
* remain available in the terminal. Callers must forward it to the PTY.
812+
* remain available in the terminal, plus whatever `source` implies. Callers
813+
* must forward it to the PTY.
793814
* @param {string} cliPath - Resolved CLI path
815+
* @param {?string} [source] - `source` from locateCli(), for the env above
794816
* @return {{command: string, args: Array<string>, env: Object}}
795817
*/
796-
function getSpawnProfile(cliPath) {
797-
const env = _cliPathEnv(cliPath, process.env);
818+
function getSpawnProfile(cliPath, source) {
819+
const env = Object.assign(_cliPathEnv(cliPath, process.env), _cliSourceEnv(source));
798820
if (isWindows && /\.(cmd|bat)$/i.test(cliPath || "")) {
799821
return { command: process.env.COMSPEC || "cmd.exe", args: ["/c", cliPath], env };
800822
}
@@ -809,6 +831,7 @@ exports.locateCli = locateCli;
809831
exports.validateCliPath = validateCliPath;
810832
exports.findDownloader = findDownloader;
811833
exports.getSpawnProfile = getSpawnProfile;
834+
exports.getSourceEnv = _cliSourceEnv;
812835
exports.compareVersions = compareVersions;
813836
exports.setBundledRoot = setBundledRoot;
814837
exports.setOverrides = setOverrides;

‎src-node/test/test-cli-locator.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ function _runProcess(command, args, env) {
7373

7474
/**
7575
* Return a launch profile and the input environment after the locator runs.
76-
* @param {Object} params - Simulated platform, env, and cliPath
76+
* @param {Object} params - Simulated platform, env, cliPath, and locate source
7777
* @return {Promise<Object>} Profile and environment for Jasmine assertions
7878
*/
79-
async function getSpawnProfile({platform, env, cliPath}) {
79+
async function getSpawnProfile({platform, env, cliPath, source}) {
8080
const locator = _loadLocator(platform, env);
81-
return { profile: locator.getSpawnProfile(cliPath), env };
81+
return { profile: locator.getSpawnProfile(cliPath, source), env };
8282
}
8383

8484
/**

‎test/spec/CLILocator-test.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ define(function (require, exports, module) {
3535
});
3636
});
3737

38+
it("should silence the auto-updater only for the bundled Claude Code", async function () {
39+
const args = {
40+
platform: "linux", env: { PATH: "/usr/bin" }, cliPath: "/opt/node/bin/claude"
41+
};
42+
// Ours: it cannot write to the npm prefix it thinks it owns, and
43+
// says so in red on every launch. Phoenix updates this copy.
44+
const bundled = await nodeConnector.execPeer("getSpawnProfile",
45+
Object.assign({ source: "bundled" }, args));
46+
expect(bundled.profile.env.DISABLE_AUTOUPDATER).toBe("1");
47+
// Theirs: left alone, so their own install can still catch up.
48+
for (const source of ["native", "fallback", "override", null]) {
49+
const own = await nodeConnector.execPeer("getSpawnProfile",
50+
Object.assign({ source }, args));
51+
expect(Object.keys(own.profile.env)).withContext(source).toEqual(["PATH"]);
52+
}
53+
});
54+
3855
it("macOS: should make Homebrew bin available to the updater", async function () {
3956
const result = await nodeConnector.execPeer("getSpawnProfile", {
4057
platform: "darwin",

0 commit comments

Comments
 (0)