From b4e7a55868057b65a1e4f349c4163e6d573a5e59 Mon Sep 17 00:00:00 2001 From: Jeremy Felder Date: Mon, 26 Oct 2026 14:40:07 +0200 Subject: [PATCH 1/2] fix: keep Claude Code background sessions authenticated with Zro Claude Code hands backgrounded sessions (left arrow, /background, --bg) to a daemon whose workers do not inherit the launching process's environment, and a respawned worker re-reads its config files long after the launch exits. The per-launch temp dir was deleted when the launched claude exited, so backgrounded sessions lost the MCP config and their gateway credentials came back 'Not logged in' when the daemon was started outside zro. Write the claude tool's mcp.json and a settings.json carrying the gateway env (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) to a stable ~/.cache/zro/claude/ path and pass it with --settings, so background and respawned sessions authenticate regardless of which process started the daemon. zro logout removes the directory. --- src/engine/tools/claude.ts | 49 ++++++++++++++++++++++++++++---------- src/run.ts | 4 +++- tests/run.test.ts | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/engine/tools/claude.ts b/src/engine/tools/claude.ts index fd37e3b..ddd549c 100644 --- a/src/engine/tools/claude.ts +++ b/src/engine/tools/claude.ts @@ -3,11 +3,21 @@ import { ENDPOINT_ROOT, MCP_URL, PROVIDER_NAME, type ZroModel } from "../constan import { jsonSerializer } from "../serializers.js"; import type { ToolModule } from "../types.js"; +// Claude Code hands backgrounded sessions to a daemon whose workers do not +// inherit this launch's environment, and a respawned worker re-reads these +// files long after the launching process exits. Both therefore live at a +// stable path rather than the per-launch temp dir. +export function claudeConfigDir(homeDir: string, env: NodeJS.ProcessEnv): string { + return path.join(env.XDG_CACHE_HOME || path.join(homeDir, ".cache"), "zro", "claude"); +} + export const claudeTool: ToolModule = { id: "claude", label: "Claude Code", async launch(ctx) { - const mcpConfigPath = path.join(ctx.tempDir, "claude", "mcp.json"); + const configDir = claudeConfigDir(ctx.homeDir, ctx.env); + const mcpConfigPath = path.join(configDir, "mcp.json"); + const settingsPath = path.join(configDir, "settings.json"); if (!ctx.models.some((model) => model.id === ctx.model)) { ctx.stderr.write( `Warning: model "${ctx.model}" is not in the Zro catalog; Claude Code will assume a 200k context window.\n` @@ -28,6 +38,8 @@ export const claudeTool: ToolModule = { buildClaudeManagedSettingsArg(ctx.models, aliasPlan), "--mcp-config", mcpConfigPath, + "--settings", + settingsPath, ...ctx.extraArgs ], env: { @@ -45,18 +57,31 @@ export const claudeTool: ToolModule = { CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", DO_NOT_TRACK: "1" }, - files: [{ - path: mcpConfigPath, - contents: jsonSerializer.stringify({ - mcpServers: { - zro: { - type: "http", - url: MCP_URL, - headers: { Authorization: `Bearer ${ctx.apiKey}` } + files: [ + { + path: mcpConfigPath, + contents: jsonSerializer.stringify({ + mcpServers: { + zro: { + type: "http", + url: MCP_URL, + headers: { Authorization: `Bearer ${ctx.apiKey}` } + } } - } - }) - }], + }), + persistent: true + }, + { + path: settingsPath, + contents: jsonSerializer.stringify({ + env: { + ANTHROPIC_BASE_URL: ENDPOINT_ROOT, + ANTHROPIC_AUTH_TOKEN: ctx.apiKey + } + }), + persistent: true + } + ], message: "Launch Claude Code with Zro" }; } diff --git a/src/run.ts b/src/run.ts index 42d9c5b..d2cff04 100644 --- a/src/run.ts +++ b/src/run.ts @@ -18,7 +18,7 @@ import { resolveApiKey, writeStoredApiKey } from "./engine/key.js"; -import { CLAUDE_MODEL_ALIAS_SLOTS, claudeTool } from "./engine/tools/claude.js"; +import { CLAUDE_MODEL_ALIAS_SLOTS, claudeConfigDir, claudeTool } from "./engine/tools/claude.js"; import { codexAppCredentialFilePath, codexAppTool, @@ -574,6 +574,8 @@ async function logout( deleteStoredApiKey({ homeDir: io.homeDir, env }), deleteOptionalFile(codexAppCredentialFilePath(io.homeDir)), invalidateModelCatalog({ homeDir: io.homeDir, env }), + fs.rm(claudeConfigDir(io.homeDir, env), { recursive: true, force: true }) + .catch(() => undefined), ]); const removed = storedKeyRemoved || codexAppKeyRemoved; io.stdout.write(output === "json" diff --git a/tests/run.test.ts b/tests/run.test.ts index 1ac791f..fd96871 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { CLAUDE_MODEL_ALIAS_SLOTS, claudeTool } from "../src/engine/tools/claude.js"; +import { ENDPOINT_ROOT } from "../src/engine/constants.js"; import { TEST_CATALOG_RESPONSE, TIER_MODELS } from "./fixtures.js"; import type { SpawnOptions, SpawnProcess } from "../src/engine/types.js"; import { run } from "../src/run.js"; @@ -329,6 +330,43 @@ describe("zro experience", () => { expect(plan.env!.ANTHROPIC_CUSTOM_MODEL_OPTION).toBe("anthropic.injected"); }); + it("launches claude with persistent config files for background sessions", async () => { + const stderr = new PassThrough(); + const plan = await claudeTool.launch({ + apiKey: "sk-stable-secret", + apiKeySource: "env", + env: {}, + model: "kimi-k3", + models: TIER_MODELS, + extraArgs: [], + homeDir: "/tmp/zro-stable-home", + cwd: "/tmp", + tempDir: "/tmp/zro-stable-session", + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr + }); + + const settingsPath = plan.args![plan.args!.indexOf("--settings") + 1]; + expect(settingsPath).toBe("/tmp/zro-stable-home/.cache/zro/claude/settings.json"); + expect(plan.args![plan.args!.indexOf("--mcp-config") + 1]) + .toBe("/tmp/zro-stable-home/.cache/zro/claude/mcp.json"); + + const settings = plan.files!.find((file) => file.path === settingsPath)!; + expect(settings.persistent).toBe(true); + expect(JSON.parse(settings.contents)).toEqual({ + env: { + ANTHROPIC_BASE_URL: ENDPOINT_ROOT, + ANTHROPIC_AUTH_TOKEN: "sk-stable-secret" + } + }); + + const mcp = plan.files!.find((file) => file.path.endsWith("mcp.json"))!; + expect(mcp.persistent).toBe(true); + expect(JSON.parse(mcp.contents).mcpServers.zro.headers.Authorization) + .toBe("Bearer sk-stable-secret"); + }); + it("forwards --alias overrides through zro again", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-again-alias-")); const stdout = new PassThrough(); @@ -658,6 +696,11 @@ describe("zro experience", () => { const catalogPath = path.join(home, ".cache", "zro", "model-catalog.json"); await fs.mkdir(path.dirname(catalogPath), { recursive: true }); await fs.writeFile(catalogPath, JSON.stringify(dynamicCatalogResponse())); + const claudeConfigDir = path.join(home, ".cache", "zro", "claude"); + await fs.mkdir(claudeConfigDir, { recursive: true }); + await fs.writeFile(path.join(claudeConfigDir, "settings.json"), + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: "sk-stored" } })); + await fs.writeFile(path.join(claudeConfigDir, "mcp.json"), "{}"); const stdout = new PassThrough(); const code = await run(["logout", "--json"], io(home, stdout)); @@ -675,6 +718,10 @@ describe("zro experience", () => { await expect(fs.stat(catalogPath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.readFile(path.join(codexAppDir, "config.toml"), "utf8")) .resolves.toBe("model = \"glm-5.3\"\n"); + await expect(fs.stat(path.join(claudeConfigDir, "settings.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(path.join(claudeConfigDir, "mcp.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); }); it("removes the Codex App key when it is the only stored credential", async () => { From 09e5658a698c50ccdeea320560b3e0dc6d39bfad Mon Sep 17 00:00:00 2001 From: Jeremy Felder Date: Mon, 26 Oct 2026 14:46:09 +0200 Subject: [PATCH 2/2] fix: make stable-path assertions platform-neutral (Windows CI) --- tests/run.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/run.test.ts b/tests/run.test.ts index fd96871..1068276 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -348,9 +348,12 @@ describe("zro experience", () => { }); const settingsPath = plan.args![plan.args!.indexOf("--settings") + 1]; - expect(settingsPath).toBe("/tmp/zro-stable-home/.cache/zro/claude/settings.json"); - expect(plan.args![plan.args!.indexOf("--mcp-config") + 1]) - .toBe("/tmp/zro-stable-home/.cache/zro/claude/mcp.json"); + expect(settingsPath).toBe( + path.join("/tmp/zro-stable-home", ".cache", "zro", "claude", "settings.json") + ); + expect(plan.args![plan.args!.indexOf("--mcp-config") + 1]).toBe( + path.join("/tmp/zro-stable-home", ".cache", "zro", "claude", "mcp.json") + ); const settings = plan.files!.find((file) => file.path === settingsPath)!; expect(settings.persistent).toBe(true);