Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
8 changes: 6 additions & 2 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 17 additions & 3 deletions src/server/system-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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
Expand All @@ -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)) {
Expand All @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions tests/claude-integration/claude-management-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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), {
Expand Down
62 changes: 62 additions & 0 deletions tests/server/system-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading