diff --git a/src/engine/tools/claude.ts b/src/engine/tools/claude.ts index b39f58a..548d2d5 100644 --- a/src/engine/tools/claude.ts +++ b/src/engine/tools/claude.ts @@ -3,11 +3,21 @@ import { PROVIDER_NAME, type ZroModel } from "../constants.js"; 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` @@ -27,6 +37,8 @@ export const claudeTool: ToolModule = { "--managed-settings", buildClaudeManagedSettingsArg(ctx.models, aliasPlan), ...(ctx.noAuth ? [] : ["--mcp-config", mcpConfigPath]), + "--settings", + settingsPath, ...ctx.extraArgs ], env: { @@ -44,18 +56,31 @@ export const claudeTool: ToolModule = { CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", DO_NOT_TRACK: "1" }, - files: ctx.noAuth ? [] : [{ - path: mcpConfigPath, - contents: jsonSerializer.stringify({ - mcpServers: { - zro: { - type: "http", - url: ctx.mcpUrl, - headers: { Authorization: `Bearer ${ctx.apiKey}` } + files: [ + ...(ctx.noAuth ? [] : [{ + path: mcpConfigPath, + contents: jsonSerializer.stringify({ + mcpServers: { + zro: { + type: "http", + url: ctx.mcpUrl, + headers: { Authorization: `Bearer ${ctx.apiKey}` } + } } - } - }) - }], + }), + persistent: true + }]), + { + path: settingsPath, + contents: jsonSerializer.stringify({ + env: { + ANTHROPIC_BASE_URL: ctx.inferenceRoot, + ANTHROPIC_AUTH_TOKEN: ctx.apiKey + } + }), + persistent: true + } + ], message: "Launch Claude Code with Zro" }; } diff --git a/src/run.ts b/src/run.ts index a6591b2..09350e8 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, @@ -598,6 +598,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 66711b2..085feec 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"; @@ -337,6 +338,50 @@ 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", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: "https://zro.moonmath.ai/v1", + mcpUrl: "https://zro.moonmath.ai/mcp/zro", + noAuth: false, + 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( + 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); + 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(); @@ -670,6 +715,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)); @@ -687,6 +737,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 () => {