From a7e6ea6ee0a5f8ddf142911f627e399d1e9ecc35 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:38:16 +0900 Subject: [PATCH 1/2] refactor(server): isolate the shell-hook side of system-env (split S09 L1/3) --- src/server/system-env-shell.ts | 238 ++++++++++++++++++++++++++++++++ src/server/system-env.ts | 241 +-------------------------------- 2 files changed, 245 insertions(+), 234 deletions(-) create mode 100644 src/server/system-env-shell.ts diff --git a/src/server/system-env-shell.ts b/src/server/system-env-shell.ts new file mode 100644 index 0000000000..35954f035e --- /dev/null +++ b/src/server/system-env-shell.ts @@ -0,0 +1,238 @@ +import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { getConfigDir } from "../config"; +import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows"; +import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens, type AuthDetectDeps } from "../claude/auth-detect"; +import { resolveClaudeAuthMode } from "../claude/auth-mode"; +import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context"; +import type { OcxConfig } from "../types"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; + +/** + * Does the opencodex dummy marker belong in the system environment? + * + * Keyed on the SAME resolver `ocx claude` uses, so an auto config with no Claude auth + * also reaches plain `claude` launches — before this, auto-absent users got nothing + * from auto-connect and the feature looked broken for exactly the people it helps + * (devlog 260726_claude_auth_auto/035). + * + * NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx + * ensure`, or a settings save). `ocx claude` re-resolves live on every launch. + */ +export type SystemEnvDeps = { + /** Test seam; production uses the authenticated Node-launcher context. */ + preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; + /** Test seam for auth sources; `env` and `ownTokens` stay bound below. */ + authDetect?: Omit, "env" | "ownTokens">; +}; + +/** + * Bun may synthesize Anthropic variables from a project `.env` before this module runs. + * Only values recorded by the plain-Node launcher are trusted as parent exports. Direct + * Bun/service launches have no proof-bound slot list, so they fail closed and let the + * file/keychain auth sources decide instead of allowing dotenv to select subscription mode. + */ +function systemEnvAnthropicEnv( + env: NodeJS.ProcessEnv, + preBunAnthropicSlots: readonly AnthropicParentEnvSlot[] | null | undefined, +): NodeJS.ProcessEnv { + const trustedSlots = preBunAnthropicSlots === undefined + ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] + : preBunAnthropicSlots ?? []; + const exported = new Set(trustedSlots); + const sanitized = { ...env }; + for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { + if (sanitized[name] !== undefined && !exported.has(name)) delete sanitized[name]; + } + return sanitized; +} + +export function systemEnvMarkerMode(config: OcxConfig, deps: SystemEnvDeps = {}): "proxy" | "subscription" { + const env = systemEnvAnthropicEnv(process.env, deps.preBunAnthropicSlots); + const ownTokens = ownAdmissionTokens(config); + return resolveClaudeAuthMode(config, detectClaudeAuth({ + ...defaultAuthDetectDeps(env, ownTokens), + ...(deps.authDetect ?? {}), + env: () => env, + ownTokens, + })).markerMode; +} + +// --------------------------------------------------------------------------- +// Shell-hook env file: written on inject, sourced by the shell hook in .zshrc. +// This works for ALL new shells immediately, unlike launchctl setenv which only +// reaches processes launched directly by launchd (not Terminal.app children). +// --------------------------------------------------------------------------- + +export function getShellEnvFilePath(): string { + return join(getConfigDir(), "claude-env.sh"); +} + +function shellValue(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function writeShellEnvFile( + port: number, + config: OcxConfig, + modelEnv: Record = {}, + auto?: AutoContextMode, + deps: SystemEnvDeps = {}, +): void { + const lines = [ + `# Generated by opencodex — do not edit manually`, + `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`, + `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`, + ]; + // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already + // exported in their shell wins even though launchctl knows nothing about it. + const conditional = (name: string, value: string) => + `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`; + if (systemEnvMarkerMode(config, deps) === "proxy") { + if (config.apiKeys?.length) { + lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`); + } else { + lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER)); + } + } + // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2). + if (modelEnv.ANTHROPIC_MODEL) { + lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`); + } else if (config.claudeCode?.model) { + lines.push(`export ANTHROPIC_MODEL=${shellValue(config.claudeCode.model)}`); + } + for (const [name, value] of Object.entries(modelEnv)) { + if (name === "ANTHROPIC_MODEL") continue; + lines.push(conditional(name, value)); + } + const maxCtx = config.claudeCode?.maxContextTokens; + if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) { + lines.push(conditional("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)))); + lines.push(conditional("DISABLE_COMPACT", "1")); + } + // Auto-context (devlog 260712 020): same contract as `ocx claude` / launchctl. + const autoShell = auto ?? resolveAutoContext(config.claudeCode); + if (autoShell.enabled) lines.push(conditional("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(autoShell.compactWindow))); + if (config.claudeCode?.alwaysEnableEffort === true) { + lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1")); + } + const shellEnvPath = getShellEnvFilePath(); + recordOwnedConfigPath(getConfigDir(), shellEnvPath); + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + writeFileSync(shellEnvPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }); +} + +export function removeShellEnvFile(): void { + try { unlinkSync(getShellEnvFilePath()); } catch { /* already gone */ } +} + +// --------------------------------------------------------------------------- +// .zshrc hook auto-install: adds a one-liner that sources claude-env.sh. +// Idempotent — skips if the hook line already exists. +// --------------------------------------------------------------------------- + +const SHELL_HOOK_MARKER = "# opencodex claude-env hook"; +const SHELL_HOOK_LINE = `${SHELL_HOOK_MARKER}\n[ -f ~/.opencodex/claude-env.sh ] && source ~/.opencodex/claude-env.sh`; + +export function installShellHook(): { installed: boolean; reason?: string } { + if (process.platform !== "darwin") return { installed: false, reason: "not macOS" }; + const home = process.env.HOME; + if (!home) return { installed: false, reason: "no HOME" }; + const zshrcPath = join(home, ".zshrc"); + try { + let content = ""; + try { content = readFileSync(zshrcPath, "utf8"); } catch { /* file doesn't exist yet */ } + if (content.includes(SHELL_HOOK_MARKER)) return { installed: false, reason: "already installed" }; + const addition = `\n${SHELL_HOOK_LINE}\n`; + writeFileSync(zshrcPath, content + addition, { encoding: "utf8", mode: 0o644 }); + return { installed: true }; + } catch (err) { + return { installed: false, reason: `write failed: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +export function uninstallShellHook(): { removed: boolean; reason?: string } { + if (process.platform !== "darwin") return { removed: false, reason: "not macOS" }; + const home = process.env.HOME; + if (!home) return { removed: false, reason: "no HOME" }; + const zshrcPath = join(home, ".zshrc"); + try { + const content = readFileSync(zshrcPath, "utf8"); + if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" }; + // Match CR?LF, not LF alone. A .zshrc with CRLF line endings — ordinary on a home + // directory an editor or another OS has touched — did not match, so the file was + // rewritten unchanged and the caller was told the hook was removed. Reporting success + // while the hook still sources on every new shell is the worse of the two failures. + const cleaned = content.replace(/\r?\n?# opencodex claude-env hook\r?\n\[.*claude-env\.sh.*(?:\r?\n)?/g, "\n"); + // Verify instead of assuming: if the marker survives, the block is shaped in a way this + // pattern does not own, and the honest answer is failure rather than a silent no-op. + if (cleaned.includes(SHELL_HOOK_MARKER)) { + return { removed: false, reason: "hook block present but not in the expected shape; remove it manually" }; + } + writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 }); + return { removed: true }; + } catch (error) { + if (error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") { + return { removed: false, reason: "not installed" }; + } + return { removed: false, reason: "read/write failed" }; + } +} + +/** Whether a real `claude` executable is discoverable from this process's PATH. */ +export function claudeCodeCliInstalled(pathValue = process.env.PATH): boolean { + if (!pathValue) return false; + for (const directory of pathValue.split(delimiter)) { + // An empty PATH segment means the current directory. Do not let the proxy treat a + // workspace-local file as a durable user installation. + if (!directory) continue; + const candidate = join(directory, "claude"); + try { + if (!statSync(candidate).isFile()) continue; + accessSync(candidate, constants.X_OK); + return true; + } catch { + // Keep scanning PATH after missing, non-file, and non-executable entries. + } + } + return false; +} + +/** + * Keep the shell hook aligned with the integration that can actually consume it. + * Claude Desktop uses its own profile and does not source `.zshrc`; this hook exists + * only for plain Claude Code CLI launches. + * + * Reconciliation is PATH-sensitive by construction: "Claude Code is installed" is answered + * from the PATH of whichever process calls this. A launchd/service context with a stripped + * PATH can therefore fail to see a `claude` the user's interactive shell finds, and this will + * remove the hook. That is the intended failure direction — removing an OpenCodex-owned block + * is reversible on the next foreground `ocx start`, whereas leaving a hook pointing at an + * uninstalled CLI is the stale state this reconciliation exists to clear. Only the block + * carrying our own marker is ever touched; user lines are preserved. + */ +export function reconcileShellHook(systemEnvInjected: boolean): { + changed: boolean; + state: "installed" | "absent" | "failed"; + reason?: string; +} { + if (process.platform !== "darwin") return { changed: false, state: "absent", reason: "not macOS" }; + if (systemEnvInjected && claudeCodeCliInstalled()) { + const result = installShellHook(); + if (result.installed) return { changed: true, state: "installed" }; + if (result.reason === "already installed") { + return { changed: false, state: "installed", reason: result.reason }; + } + return { changed: false, state: "failed", reason: result.reason ?? "install failed" }; + } + + const result = uninstallShellHook(); + if (!result.removed && result.reason !== "not installed") { + return { changed: false, state: "failed", reason: result.reason ?? "remove failed" }; + } + return { + changed: result.removed, + state: "absent", + reason: systemEnvInjected ? "Claude Code not installed" : "system environment inactive", + }; +} diff --git a/src/server/system-env.ts b/src/server/system-env.ts index 777fdd5828..5825b2a2a1 100644 --- a/src/server/system-env.ts +++ b/src/server/system-env.ts @@ -1,245 +1,18 @@ import { execFileSync } from "node:child_process"; -import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; import { getConfigDir } from "../config"; import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows"; -import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens, type AuthDetectDeps } from "../claude/auth-detect"; -import { resolveClaudeAuthMode } from "../claude/auth-mode"; -import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context"; +import { PROXY_MARKER } from "../claude/auth-detect"; import { isProxyAdmissionSecret } from "./auth-cors"; import type { OcxConfig } from "../types"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { providerContextCap } from "../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; - -/** - * Does the opencodex dummy marker belong in the system environment? - * - * Keyed on the SAME resolver `ocx claude` uses, so an auto config with no Claude auth - * also reaches plain `claude` launches — before this, auto-absent users got nothing - * from auto-connect and the feature looked broken for exactly the people it helps - * (devlog 260726_claude_auth_auto/035). - * - * NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx - * ensure`, or a settings save). `ocx claude` re-resolves live on every launch. - */ -export type SystemEnvDeps = { - /** Test seam; production uses the authenticated Node-launcher context. */ - preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; - /** Test seam for auth sources; `env` and `ownTokens` stay bound below. */ - authDetect?: Omit, "env" | "ownTokens">; -}; - -/** - * Bun may synthesize Anthropic variables from a project `.env` before this module runs. - * Only values recorded by the plain-Node launcher are trusted as parent exports. Direct - * Bun/service launches have no proof-bound slot list, so they fail closed and let the - * file/keychain auth sources decide instead of allowing dotenv to select subscription mode. - */ -function systemEnvAnthropicEnv( - env: NodeJS.ProcessEnv, - preBunAnthropicSlots: readonly AnthropicParentEnvSlot[] | null | undefined, -): NodeJS.ProcessEnv { - const trustedSlots = preBunAnthropicSlots === undefined - ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] - : preBunAnthropicSlots ?? []; - const exported = new Set(trustedSlots); - const sanitized = { ...env }; - for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { - if (sanitized[name] !== undefined && !exported.has(name)) delete sanitized[name]; - } - return sanitized; -} - -function systemEnvMarkerMode(config: OcxConfig, deps: SystemEnvDeps = {}): "proxy" | "subscription" { - const env = systemEnvAnthropicEnv(process.env, deps.preBunAnthropicSlots); - const ownTokens = ownAdmissionTokens(config); - return resolveClaudeAuthMode(config, detectClaudeAuth({ - ...defaultAuthDetectDeps(env, ownTokens), - ...(deps.authDetect ?? {}), - env: () => env, - ownTokens, - })).markerMode; -} - -// --------------------------------------------------------------------------- -// Shell-hook env file: written on inject, sourced by the shell hook in .zshrc. -// This works for ALL new shells immediately, unlike launchctl setenv which only -// reaches processes launched directly by launchd (not Terminal.app children). -// --------------------------------------------------------------------------- - -export function getShellEnvFilePath(): string { - return join(getConfigDir(), "claude-env.sh"); -} - -function shellValue(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -function writeShellEnvFile( - port: number, - config: OcxConfig, - modelEnv: Record = {}, - auto?: AutoContextMode, - deps: SystemEnvDeps = {}, -): void { - const lines = [ - `# Generated by opencodex — do not edit manually`, - `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`, - `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`, - ]; - // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already - // exported in their shell wins even though launchctl knows nothing about it. - const conditional = (name: string, value: string) => - `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`; - if (systemEnvMarkerMode(config, deps) === "proxy") { - if (config.apiKeys?.length) { - lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`); - } else { - lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER)); - } - } - // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2). - if (modelEnv.ANTHROPIC_MODEL) { - lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`); - } else if (config.claudeCode?.model) { - lines.push(`export ANTHROPIC_MODEL=${shellValue(config.claudeCode.model)}`); - } - for (const [name, value] of Object.entries(modelEnv)) { - if (name === "ANTHROPIC_MODEL") continue; - lines.push(conditional(name, value)); - } - const maxCtx = config.claudeCode?.maxContextTokens; - if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) { - lines.push(conditional("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)))); - lines.push(conditional("DISABLE_COMPACT", "1")); - } - // Auto-context (devlog 260712 020): same contract as `ocx claude` / launchctl. - const autoShell = auto ?? resolveAutoContext(config.claudeCode); - if (autoShell.enabled) lines.push(conditional("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(autoShell.compactWindow))); - if (config.claudeCode?.alwaysEnableEffort === true) { - lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1")); - } - const shellEnvPath = getShellEnvFilePath(); - recordOwnedConfigPath(getConfigDir(), shellEnvPath); - mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - writeFileSync(shellEnvPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }); -} - -function removeShellEnvFile(): void { - try { unlinkSync(getShellEnvFilePath()); } catch { /* already gone */ } -} - -// --------------------------------------------------------------------------- -// .zshrc hook auto-install: adds a one-liner that sources claude-env.sh. -// Idempotent — skips if the hook line already exists. -// --------------------------------------------------------------------------- - -const SHELL_HOOK_MARKER = "# opencodex claude-env hook"; -const SHELL_HOOK_LINE = `${SHELL_HOOK_MARKER}\n[ -f ~/.opencodex/claude-env.sh ] && source ~/.opencodex/claude-env.sh`; - -export function installShellHook(): { installed: boolean; reason?: string } { - if (process.platform !== "darwin") return { installed: false, reason: "not macOS" }; - const home = process.env.HOME; - if (!home) return { installed: false, reason: "no HOME" }; - const zshrcPath = join(home, ".zshrc"); - try { - let content = ""; - try { content = readFileSync(zshrcPath, "utf8"); } catch { /* file doesn't exist yet */ } - if (content.includes(SHELL_HOOK_MARKER)) return { installed: false, reason: "already installed" }; - const addition = `\n${SHELL_HOOK_LINE}\n`; - writeFileSync(zshrcPath, content + addition, { encoding: "utf8", mode: 0o644 }); - return { installed: true }; - } catch (err) { - return { installed: false, reason: `write failed: ${err instanceof Error ? err.message : String(err)}` }; - } -} - -export function uninstallShellHook(): { removed: boolean; reason?: string } { - if (process.platform !== "darwin") return { removed: false, reason: "not macOS" }; - const home = process.env.HOME; - if (!home) return { removed: false, reason: "no HOME" }; - const zshrcPath = join(home, ".zshrc"); - try { - const content = readFileSync(zshrcPath, "utf8"); - if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" }; - // Match CR?LF, not LF alone. A .zshrc with CRLF line endings — ordinary on a home - // directory an editor or another OS has touched — did not match, so the file was - // rewritten unchanged and the caller was told the hook was removed. Reporting success - // while the hook still sources on every new shell is the worse of the two failures. - const cleaned = content.replace(/\r?\n?# opencodex claude-env hook\r?\n\[.*claude-env\.sh.*(?:\r?\n)?/g, "\n"); - // Verify instead of assuming: if the marker survives, the block is shaped in a way this - // pattern does not own, and the honest answer is failure rather than a silent no-op. - if (cleaned.includes(SHELL_HOOK_MARKER)) { - return { removed: false, reason: "hook block present but not in the expected shape; remove it manually" }; - } - writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 }); - return { removed: true }; - } catch (error) { - if (error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") { - return { removed: false, reason: "not installed" }; - } - return { removed: false, reason: "read/write failed" }; - } -} - -/** Whether a real `claude` executable is discoverable from this process's PATH. */ -export function claudeCodeCliInstalled(pathValue = process.env.PATH): boolean { - if (!pathValue) return false; - for (const directory of pathValue.split(delimiter)) { - // An empty PATH segment means the current directory. Do not let the proxy treat a - // workspace-local file as a durable user installation. - if (!directory) continue; - const candidate = join(directory, "claude"); - try { - if (!statSync(candidate).isFile()) continue; - accessSync(candidate, constants.X_OK); - return true; - } catch { - // Keep scanning PATH after missing, non-file, and non-executable entries. - } - } - return false; -} - -/** - * Keep the shell hook aligned with the integration that can actually consume it. - * Claude Desktop uses its own profile and does not source `.zshrc`; this hook exists - * only for plain Claude Code CLI launches. - * - * Reconciliation is PATH-sensitive by construction: "Claude Code is installed" is answered - * from the PATH of whichever process calls this. A launchd/service context with a stripped - * PATH can therefore fail to see a `claude` the user's interactive shell finds, and this will - * remove the hook. That is the intended failure direction — removing an OpenCodex-owned block - * is reversible on the next foreground `ocx start`, whereas leaving a hook pointing at an - * uninstalled CLI is the stale state this reconciliation exists to clear. Only the block - * carrying our own marker is ever touched; user lines are preserved. - */ -export function reconcileShellHook(systemEnvInjected: boolean): { - changed: boolean; - state: "installed" | "absent" | "failed"; - reason?: string; -} { - if (process.platform !== "darwin") return { changed: false, state: "absent", reason: "not macOS" }; - if (systemEnvInjected && claudeCodeCliInstalled()) { - const result = installShellHook(); - if (result.installed) return { changed: true, state: "installed" }; - if (result.reason === "already installed") { - return { changed: false, state: "installed", reason: result.reason }; - } - return { changed: false, state: "failed", reason: result.reason ?? "install failed" }; - } - - const result = uninstallShellHook(); - if (!result.removed && result.reason !== "not installed") { - return { changed: false, state: "failed", reason: result.reason ?? "remove failed" }; - } - return { - changed: result.removed, - state: "absent", - reason: systemEnvInjected ? "Claude Code not installed" : "system environment inactive", - }; -} +export { getShellEnvFilePath, installShellHook, uninstallShellHook, claudeCodeCliInstalled, reconcileShellHook } from "./system-env-shell"; +export type { SystemEnvDeps } from "./system-env-shell"; +import { systemEnvMarkerMode, writeShellEnvFile, removeShellEnvFile } from "./system-env-shell"; +import type { SystemEnvDeps } from "./system-env-shell"; const SYSTEM_ENV_NAMES = [ "ANTHROPIC_BASE_URL", From 1cab08d405fc59bc5b386aa21a073f4301246ac2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:39:50 +0900 Subject: [PATCH 2/2] test(server): cover the system-env shell seam (split S09 L1/3) --- tests/server/system-env.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/server/system-env.test.ts b/tests/server/system-env.test.ts index e44ca7b43e..a8c4175198 100644 --- a/tests/server/system-env.test.ts +++ b/tests/server/system-env.test.ts @@ -1,12 +1,19 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as childProcess from "node:child_process"; import * as fs from "node:fs"; +import { repoPath } from "../helpers/repo-root"; import type { OcxConfig } from "../../src/types"; import { cleanStaleSystemEnv, + getShellEnvFilePath, injectSystemEnv, + installShellHook, revertSystemEnv, } from "../../src/server/system-env"; +import { + getShellEnvFilePath as shellEnvFilePath, + installShellHook as shellInstallHook, +} from "../../src/server/system-env-shell"; const originalFetch = globalThis.fetch; const originalPlatform = process.platform; @@ -474,3 +481,12 @@ describe("systemEnv lever keys (devlog 136 B6)", () => { expect(shellWrite!.data).toContain('[ -z "${ANTHROPIC_DEFAULT_OPUS_MODEL+x}" ] && export ANTHROPIC_DEFAULT_OPUS_MODEL='); }); }); + +test("system-env preserves the shell seam without a back-import", () => { + readSpy.mockRestore(); + expect(installShellHook).toBe(shellInstallHook); + expect(getShellEnvFilePath).toBe(shellEnvFilePath); + const shellSource = fs.readFileSync(repoPath("src/server/system-env-shell.ts"), "utf8"); + expect(shellSource.split("\n").some(line => /from\s+["']\.\/system-env["']/.test(line))).toBe(false); + expect(fs.readFileSync(repoPath("src/server/system-env.ts"), "utf8")).toContain("catalog_busy"); +});