diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index c1b06b27f82..1297e4d0fb5 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -265,6 +265,12 @@ When `claudeCode.systemEnv` is set to `true` (default: **off**), `ocx start` use to inject `ANTHROPIC_BASE_URL` and the related Claude Code environment variables system-wide. New terminal windows and tabs therefore route plain `claude` commands through the proxy without requiring the `ocx claude` wrapper. Already-open shells are unaffected and must be reopened. +Changing a model slot or lever (`smallFastModel`, `tierModels`, `maxContextTokens`, auto-context, +`autoCompactWindow`, `alwaysEnableEffort`) re-applies the injection right away: a key opencodex injected earlier is +updated, or unset once the setting no longer produces it. A value you set yourself with +`launchctl setenv` before opencodex injects that key is never overwritten or removed; a key +opencodex injected stays opencodex-owned (refreshed, unset, and removed by `ocx stop`) even if +you change its value by hand. `ocx stop` and proxy shutdown **unset the injected keys** (it does not restore previous values — only the keys opencodex injected are removed). The proxy also writes `~/.opencodex/claude-env.sh`; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d15b5117e3b..00c5fd2fd61 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1630,8 +1630,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const warnings: string[] = []; // authMode changes must reconcile the injected system env too: switching back to // Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN - // (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist). - if (body.systemEnv !== undefined || body.authMode !== undefined) { + // (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist). Model slots and + // levers feed the same injection, so a changed or cleared slot must not linger in + // launchd until the next restart. + const systemEnvInputs = ["systemEnv", "authMode", "model", "smallFastModel", "tierModels", + "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow"] as const; + if (systemEnvInputs.some(field => body[field] !== undefined)) { try { await applySystemEnvToggle(config, config.port); } catch (err) { diff --git a/src/server/system-env.ts b/src/server/system-env.ts index fc628a8f96a..1d38d53c669 100644 --- a/src/server/system-env.ts +++ b/src/server/system-env.ts @@ -282,9 +282,12 @@ export async function injectSystemEnv( } // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the // launchd domain, and track ONLY the keys we actually injected so revert cannot - // delete a pre-existing user value (audit 139 #3). + // delete a pre-existing user value (audit 139 #3). A key we already track is ours + // (revertSystemEnv unsets it regardless of value), so it is refreshed, not skipped. + const producedLevers = new Set(); const injectLever = (name: string, value: string) => { - if (launchctlGetenv(name) !== undefined) return; + producedLevers.add(name); + if (launchctlGetenv(name) !== undefined && !injectedKeys.includes(name)) return; inject(name, value); }; // Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking @@ -293,7 +296,9 @@ export async function injectSystemEnv( // Auto-context: a user-owned launchd value drives the marking predicate so the // marker and threshold never separate (audit 021 #2); injectLever's user-wins // check below keeps that value untouched. - const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW"); + const userAutoCompact = injectedKeys.includes("CLAUDE_CODE_AUTO_COMPACT_WINDOW") + ? undefined + : launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW"); const auto = resolveAutoContext(config.claudeCode, userAutoCompact); const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto); for (const [name, value] of Object.entries(modelEnv)) { @@ -315,6 +320,15 @@ export async function injectSystemEnv( // instead of only terminal sessions. injectLever keeps a user-owned launchd value. const toolSearch = claudeToolSearchEnv(config.claudeCode?.toolSearch); if (toolSearch !== undefined) injectLever("ENABLE_TOOL_SEARCH", toolSearch); + // A lever injected on an earlier run that this config no longer produces (a cleared + // smallFastModel, a removed tier slot) would otherwise stay in launchd until the proxy + // stops. Only tracked keys are touched, so a user-owned value is never removed. + for (const name of [...injectedKeys]) { + if ((SYSTEM_ENV_NAMES as readonly string[]).includes(name) || producedLevers.has(name)) continue; + unsetLaunchctlEnv(name); + injectedKeys.splice(injectedKeys.indexOf(name), 1); + writeTracking(port, injectedKeys, tracked); + } // Shell-hook env file: works for new shells in already-running Terminal.app. writeShellEnvFile(port, config, modelEnv, auto, deps); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 751b838ee99..db1e0339291 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -382,6 +382,50 @@ test("authMode-only PUT triggers system-env reconciliation (audit R2 #1)", async } }); +// Model-slot and lever fields feed injectSystemEnv, so changing one must reconcile launchd too; +// before, only systemEnv/authMode did, and a cleared smallFastModel stayed injected until restart. +test.each([ + ["smallFastModel", ""], + ["model", ""], + ["tierModels", { opus: "mock/test-model" }], + ["maxContextTokens", 1_000_000], + ["alwaysEnableEffort", true], + ["autoContext", false], + ["autoCompactWindow", 400_000], +] as const)("%s-only PUT triggers system-env reconciliation", async (field, value) => { + const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); + const server = startServer(0); + try { + const r = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [field]: value }), + }); + expect(r.status).toBe(200); + expect(applySpy).toHaveBeenCalled(); + } finally { + applySpy.mockRestore(); + await server.stop(true); + } +}); + +test("a PUT that touches no system-env input does not reconcile launchd", async () => { + const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); + const server = startServer(0); + try { + const r = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockedSkills: null }), + }); + expect(r.status).toBe(200); + expect(applySpy).not.toHaveBeenCalled(); + } finally { + applySpy.mockRestore(); + await server.stop(true); + } +}); + test("Claude sidecar overrides round-trip, partially update, clear, and reject unknown backends", async () => { const server = startServer(0); const put = (body: unknown) => fetch(new URL("/api/claude-code", server.url), { diff --git a/tests/server/system-env.test.ts b/tests/server/system-env.test.ts index 6a3d61cb7aa..0f84b724290 100644 --- a/tests/server/system-env.test.ts +++ b/tests/server/system-env.test.ts @@ -634,6 +634,68 @@ describe("systemEnv lever keys (devlog 136 B6)", () => { const shellWrite = writes.find(w => w.path.includes("claude-env.sh")); expect(shellWrite!.data).toContain('[ -z "${ANTHROPIC_DEFAULT_OPUS_MODEL+x}" ] && export ANTHROPIC_DEFAULT_OPUS_MODEL='); }); + + // A slot opencodex injected earlier is opencodex-owned (revertSystemEnv already unsets every + // tracked key regardless of its value). Re-injection must therefore refresh it and drop it once + // the config stops producing it; before this, the user-wins guard froze the old value in launchd + // until the proxy restarted. + function trackingWithLevers(keys: string[]): string { + return JSON.stringify({ + pid: 123, port: 4096, injectedAt: "2026-07-11T00:00:00.000Z", + injectedKeys: ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", ...keys], + }); + } + + test("re-inject refreshes a tracked slot whose configured value changed", async () => { + const writes = capturedWrites(); + trackingFile = trackingWithLevers(["ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.ANTHROPIC_DEFAULT_HAIKU_MODEL = "mock/old-small"; + launchctlEnvValues.ANTHROPIC_SMALL_FAST_MODEL = "mock/old-small"; + const config = { ...baseConfig, claudeCode: { systemEnv: true, smallFastModel: "mock/new-small" } } satisfies OcxConfig; + expect(await injectSystemEnv(4096, config)).toEqual({ injected: true }); + const setCalls = launchctlCommands(); + expect(setCalls).toContain("launchctl setenv ANTHROPIC_DEFAULT_HAIKU_MODEL mock/new-small"); + expect(setCalls).toContain("launchctl setenv ANTHROPIC_SMALL_FAST_MODEL mock/new-small"); + const keys = JSON.parse(writes.filter(w => w.path.includes("system-env-port")).at(-1)!.data).injectedKeys as string[]; + expect(keys).toEqual(expect.arrayContaining(["ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"])); + }); + + test("re-inject unsets a tracked slot the config no longer produces", async () => { + const writes = capturedWrites(); + trackingFile = trackingWithLevers(["ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.ANTHROPIC_DEFAULT_HAIKU_MODEL = "mock/old-small"; + launchctlEnvValues.ANTHROPIC_SMALL_FAST_MODEL = "mock/old-small"; + expect(await injectSystemEnv(4096, baseConfig)).toEqual({ injected: true }); + const setCalls = launchctlCommands(); + expect(setCalls).toContain("launchctl unsetenv ANTHROPIC_DEFAULT_HAIKU_MODEL"); + expect(setCalls).toContain("launchctl unsetenv ANTHROPIC_SMALL_FAST_MODEL"); + const keys = JSON.parse(writes.filter(w => w.path.includes("system-env-port")).at(-1)!.data).injectedKeys as string[]; + expect(keys).not.toContain("ANTHROPIC_DEFAULT_HAIKU_MODEL"); + expect(keys).not.toContain("ANTHROPIC_SMALL_FAST_MODEL"); + expect(keys).toEqual(expect.arrayContaining(["ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"])); + }); + + test("a tracked auto-compact value is refreshed, not read back as a user override", async () => { + capturedWrites(); + trackingFile = trackingWithLevers(["CLAUDE_CODE_AUTO_COMPACT_WINDOW"]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "500000"; + expect(await injectSystemEnv(4096, baseConfig)).toEqual({ injected: true }); + expect(launchctlCommands()).toContain("launchctl setenv CLAUDE_CODE_AUTO_COMPACT_WINDOW 829800"); + }); + + test("an untracked (user-owned) slot is neither overwritten nor unset on re-inject", async () => { + capturedWrites(); + trackingFile = trackingWithLevers([]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.ANTHROPIC_DEFAULT_HAIKU_MODEL = "user/own-haiku"; + const config = { ...baseConfig, claudeCode: { systemEnv: true, smallFastModel: "mock/new-small" } } satisfies OcxConfig; + expect(await injectSystemEnv(4096, config)).toEqual({ injected: true }); + const haikuCalls = launchctlCommands().filter(c => c.includes("ANTHROPIC_DEFAULT_HAIKU_MODEL") && !c.includes("getenv")); + expect(haikuCalls).toEqual([]); + }); }); test("system-env preserves the shell seam without a back-import", () => {