diff --git a/packages/adapters/codex-local/src/server/auth-precedence.test.ts b/packages/adapters/codex-local/src/server/auth-precedence.test.ts new file mode 100644 index 00000000000..80a4705f5b8 --- /dev/null +++ b/packages/adapters/codex-local/src/server/auth-precedence.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + CODEX_SANDBOX_AUTH_EXISTS_COMMAND, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE, + resolveCodexAuthPrecedence, +} from "./auth-precedence.js"; + +describe("resolveCodexAuthPrecedence", () => { + describe("precedence order", () => { + it("configured_api_key wins over all other sources", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: true, + hostAuthJson: true, + sandboxAuthJson: true, + }); + expect(result.winner).toBe("configured_api_key"); + }); + + it("host_auth_json wins when no configured api key", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: false, + hostAuthJson: true, + sandboxAuthJson: true, + }); + expect(result.winner).toBe("host_auth_json"); + }); + + it("sandbox_auth_json wins when no host credentials", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: false, + hostAuthJson: false, + sandboxAuthJson: true, + }); + expect(result.winner).toBe("sandbox_auth_json"); + }); + + it("none when no credentials present", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: false, + hostAuthJson: false, + sandboxAuthJson: false, + }); + expect(result.winner).toBe("none"); + }); + }); + + describe("sandbox login shadowing and warning", () => { + it("warns when configured_api_key shadows sandbox login", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: true, + hostAuthJson: false, + sandboxAuthJson: true, + }); + expect(result.sandboxLoginShadowed).toBe(true); + expect(result.shouldWarn).toBe(true); + }); + + it("warns when host_auth_json shadows sandbox login", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: false, + hostAuthJson: true, + sandboxAuthJson: true, + }); + expect(result.sandboxLoginShadowed).toBe(true); + expect(result.shouldWarn).toBe(true); + }); + + it("does not warn when sandbox login wins (no host credentials)", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: false, + hostAuthJson: false, + sandboxAuthJson: true, + }); + expect(result.sandboxLoginShadowed).toBe(false); + expect(result.shouldWarn).toBe(false); + }); + + it("does not warn when no sandbox login exists (sandbox-only gate)", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: true, + hostAuthJson: true, + sandboxAuthJson: false, + }); + expect(result.sandboxLoginShadowed).toBe(false); + expect(result.shouldWarn).toBe(false); + }); + + it("does not warn when no credentials exist at all (fail-open)", () => { + const result = resolveCodexAuthPrecedence({ + configuredApiKey: false, + hostAuthJson: false, + sandboxAuthJson: false, + }); + expect(result.sandboxLoginShadowed).toBe(false); + expect(result.shouldWarn).toBe(false); + }); + }); + + describe("constants", () => { + it("warning message is stable and human-readable", () => { + expect(CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING).toBe( + "snapshot login present but configured or host credentials take precedence", + ); + }); + + it("log line wraps the warning message", () => { + expect(CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE).toContain( + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + ); + expect(CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE).toMatch( + /^\[paperclip\] Warning:/, + ); + }); + + it("sandbox auth exists command is a static shell test", () => { + expect(CODEX_SANDBOX_AUTH_EXISTS_COMMAND).toBe( + 'test -f "$HOME/.codex/auth.json"', + ); + expect(CODEX_SANDBOX_AUTH_EXISTS_COMMAND).not.toContain("cat"); + expect(CODEX_SANDBOX_AUTH_EXISTS_COMMAND).not.toContain("readFile"); + }); + }); +}); \ No newline at end of file diff --git a/packages/adapters/codex-local/src/server/auth-precedence.ts b/packages/adapters/codex-local/src/server/auth-precedence.ts new file mode 100644 index 00000000000..734a25617e9 --- /dev/null +++ b/packages/adapters/codex-local/src/server/auth-precedence.ts @@ -0,0 +1,46 @@ +export const CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING = + "snapshot login present but configured or host credentials take precedence"; +export const CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE = + `[paperclip] Warning: ${CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING}.\n`; +export const CODEX_SANDBOX_AUTH_EXISTS_COMMAND = + 'test -f "$HOME/.codex/auth.json"'; + +export type CodexAuthPrecedenceWinner = + | "configured_api_key" + | "host_auth_json" + | "sandbox_auth_json" + | "none"; + +export interface CodexAuthPrecedenceInput { + configuredApiKey: boolean; + hostAuthJson: boolean; + sandboxAuthJson: boolean; +} + +export interface CodexAuthPrecedenceResolution { + winner: CodexAuthPrecedenceWinner; + sandboxLoginShadowed: boolean; + shouldWarn: boolean; +} + +export function resolveCodexAuthPrecedence( + input: CodexAuthPrecedenceInput, +): CodexAuthPrecedenceResolution { + const winner: CodexAuthPrecedenceWinner = + input.configuredApiKey + ? "configured_api_key" + : input.hostAuthJson + ? "host_auth_json" + : input.sandboxAuthJson + ? "sandbox_auth_json" + : "none"; + const sandboxLoginShadowed = + input.sandboxAuthJson && + (winner === "configured_api_key" || winner === "host_auth_json"); + + return { + winner, + sandboxLoginShadowed, + shouldWarn: sandboxLoginShadowed, + }; +} \ No newline at end of file diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 02643e2c477..f1c39ef4ada 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,4 +1,13 @@ export { execute, ensureCodexSkillsInjected } from "./execute.js"; +export { + resolveCodexAuthPrecedence, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE, + CODEX_SANDBOX_AUTH_EXISTS_COMMAND, + type CodexAuthPrecedenceInput, + type CodexAuthPrecedenceResolution, + type CodexAuthPrecedenceWinner, +} from "./auth-precedence.js"; export * from "./acp.js"; export { getConfigSchema } from "./config-schema.js"; export { @@ -82,4 +91,4 @@ export const sessionCodec: AdapterSessionCodec = { null ); }, -}; +}; \ No newline at end of file