From 3a727bf78067f6c3643a94d8f19e0b32ea1f580d Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 15 Jul 2026 10:02:28 -0700 Subject: [PATCH] fix(codex): warn when sandbox auth is shadowed (#9259) --- packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/types.ts | 10 ++ .../src/server/auth-precedence.test.ts | 124 ++++++++++++++ .../codex-local/src/server/auth-precedence.ts | 46 +++++ .../server/execute.auth-precedence.test.ts | 162 ++++++++++++++++++ .../codex-local/src/server/execute.ts | 89 +++++++++- .../adapters/codex-local/src/server/index.ts | 9 + server/src/adapters/index.ts | 1 + server/src/services/heartbeat.ts | 15 ++ 9 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/codex-local/src/server/auth-precedence.test.ts create mode 100644 packages/adapters/codex-local/src/server/auth-precedence.ts create mode 100644 packages/adapters/codex-local/src/server/execute.auth-precedence.test.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 18e91046e3..dc5db8865f 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -6,6 +6,7 @@ export type { AdapterRuntimeServiceReport, AdapterExecutionResult, AdapterInvocationMeta, + AdapterRuntimeEvent, AdapterRuntimeMcpServer, AdapterRuntimeMcpAccess, AdapterExecutionContext, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 4220470541..b0b16682e2 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -144,6 +144,15 @@ export interface AdapterRuntimeMcpAccess { getServers(): AdapterRuntimeMcpServer[]; } +export interface AdapterRuntimeEvent { + eventType: string; + stream?: "system" | "stdout" | "stderr"; + level?: "info" | "warn" | "error"; + color?: string; + message?: string; + payload?: Record; +} + export interface AdapterExecutionContext { runId: string; agent: AdapterAgent; @@ -162,6 +171,7 @@ export interface AdapterExecutionContext { runtimeMcp?: AdapterRuntimeMcpAccess; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; + onEvent?: (event: AdapterRuntimeEvent) => Promise; onRuntimeProgress?: RuntimeStatusSink; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; authToken?: string; 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 0000000000..17b0c31ba7 --- /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"); + }); + }); +}); 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 0000000000..75558b88a8 --- /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, + }; +} diff --git a/packages/adapters/codex-local/src/server/execute.auth-precedence.test.ts b/packages/adapters/codex-local/src/server/execute.auth-precedence.test.ts new file mode 100644 index 0000000000..1f66d4c567 --- /dev/null +++ b/packages/adapters/codex-local/src/server/execute.auth-precedence.test.ts @@ -0,0 +1,162 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CODEX_SANDBOX_AUTH_EXISTS_COMMAND, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE, +} from "./auth-precedence.js"; + +const { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + prepareAdapterExecutionTargetRuntime, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, + startAdapterExecutionTargetPaperclipBridge, +} = vi.hoisted(() => ({ + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined), + prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({ + target: { kind: "remote", transport: "sandbox", remoteCwd: "/sandbox/workspace" }, + workspaceRemoteDir: "/sandbox/workspace", + runtimeRootDir: "/sandbox/.paperclip-runtime", + assetDirs: { home: "/sandbox/.paperclip-runtime/codex/home" }, + restoreWorkspace: vi.fn(async () => undefined), + })), + resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "/usr/bin/codex"), + runAdapterExecutionTargetProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + runAdapterExecutionTargetShellCommand: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + })), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => null), +})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + prepareAdapterExecutionTargetRuntime, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + +import { execute } from "./execute.js"; + +describe("codex sandbox auth precedence warning", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("logs and emits a run event when sandbox login is shadowed by host auth", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-precedence-")); + cleanupDirs.push(root); + const workspaceDir = path.join(root, "workspace"); + const hostCodexHome = path.join(root, "host-codex-home"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(hostCodexHome, { recursive: true }); + await fs.writeFile( + path.join(hostCodexHome, "auth.json"), + JSON.stringify({ OPENAI_API_KEY: "fake-host-auth" }), + "utf8", + ); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const events: Array<{ eventType: string; level?: string; message?: string; payload?: Record }> = []; + + await execute({ + runId: "run-auth-precedence", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + command: "codex", + cwd: workspaceDir, + env: { CODEX_HOME: hostCodexHome }, + }, + context: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fixture", + remoteCwd: "/workspace", + runner: { execute: vi.fn() }, + }, + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + onEvent: async (event) => { + events.push(event); + }, + }); + + expect(prepareAdapterExecutionTargetRuntime).toHaveBeenCalledWith(expect.objectContaining({ + assets: [expect.objectContaining({ key: "home", localDir: hostCodexHome })], + })); + expect(runAdapterExecutionTargetShellCommand).toHaveBeenCalledWith( + "run-auth-precedence", + expect.objectContaining({ kind: "remote", transport: "sandbox", remoteCwd: "/sandbox/workspace" }), + CODEX_SANDBOX_AUTH_EXISTS_COMMAND, + expect.objectContaining({ env: {}, timeoutSec: 5 }), + ); + expect(logs).toContainEqual({ + stream: "stderr", + chunk: CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE, + }); + expect(events).toContainEqual({ + eventType: "codex.auth_precedence_warning", + stream: "system", + level: "warn", + message: CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + payload: { + configuredApiKey: false, + hostAuthJson: true, + sandboxAuthJson: true, + winner: "host_auth_json", + sandboxLoginShadowed: true, + }, + }); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f46ff4f59f..36fa7ae2c1 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -17,6 +17,7 @@ import { resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; import { @@ -54,6 +55,7 @@ import { isCodexUnknownSessionError, } from "./parse.js"; import { + codexHomeHasUsableAuth, evaluateCodexCredentialReadiness, isManagedCodexHomePath, pathExists, @@ -65,6 +67,12 @@ import { writeManagedCodexMcpConfig, type ManagedCodexMcpGateway, } from "./codex-home.js"; +import { + CODEX_SANDBOX_AUTH_EXISTS_COMMAND, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE, + resolveCodexAuthPrecedence, +} from "./auth-precedence.js"; import { prepareCodexRuntimeConfig } from "./runtime-config.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; import { buildCodexExecArgs } from "./codex-args.js"; @@ -270,6 +278,76 @@ function managedMcpGatewaysFromContext(context: Record): Manage .filter((gateway): gateway is ManagedCodexMcpGateway => Boolean(gateway)); } +type ResolvedExecutionTarget = ReturnType; +type MaybeResolvedExecutionTarget = ResolvedExecutionTarget | undefined; + +async function sandboxCodexAuthJsonExists(input: { + runId: string; + target: MaybeResolvedExecutionTarget; + cwd: string; +}): Promise { + if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + return false; + } + + try { + const result = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + CODEX_SANDBOX_AUTH_EXISTS_COMMAND, + { + cwd: input.cwd, + env: {}, + timeoutSec: 5, + }, + ); + return !result.timedOut && result.exitCode === 0; + } catch { + return false; + } +} + +async function emitSandboxAuthPrecedenceWarningIfNeeded(input: { + runId: string; + target: MaybeResolvedExecutionTarget; + cwd: string; + configuredApiKey: boolean; + hostAuthJson: boolean; + onLog: AdapterExecutionContext["onLog"]; + onEvent: AdapterExecutionContext["onEvent"]; +}): Promise { + if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + return; + } + + const sandboxAuthJson = await sandboxCodexAuthJsonExists({ + runId: input.runId, + target: input.target, + cwd: input.cwd, + }); + const resolution = resolveCodexAuthPrecedence({ + configuredApiKey: input.configuredApiKey, + hostAuthJson: input.hostAuthJson, + sandboxAuthJson, + }); + if (!resolution.shouldWarn) return; + + await input.onLog("stderr", CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE); + await input.onEvent?.({ + eventType: "codex.auth_precedence_warning", + stream: "system", + level: "warn", + message: CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING, + payload: { + configuredApiKey: input.configuredApiKey, + hostAuthJson: input.hostAuthJson, + sandboxAuthJson, + winner: resolution.winner, + sandboxLoginShadowed: resolution.sandboxLoginShadowed, + }, + }); +} + function buildCodexTransientHandoffNote(input: { previousSessionId: string | null; fallbackMode: CodexTransientFallbackMode; @@ -372,7 +450,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...paperclipBaseEnv }; diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 02643e2c47..96bb912145 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 { diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 6d2ea8c6c3..72c681c63d 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -17,6 +17,7 @@ export type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta, + AdapterRuntimeEvent, AdapterRuntimeMcpServer, AdapterRuntimeMcpAccess, AdapterModelProfileDefinition, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 448b2c3575..62a3f26904 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -76,6 +76,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterModelProfileDefinition, + AdapterRuntimeEvent, AdapterRuntimeMcpAccess, AdapterRuntimeMcpServer, AdapterSessionCodec, @@ -12851,6 +12852,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); }; + const onAdapterEvent = async (event: AdapterRuntimeEvent) => { + const eventType = event.eventType.trim(); + if (!eventType) return; + await appendRunEvent(currentRun, seq++, { + eventType: eventType.slice(0, 120), + stream: event.stream, + level: event.level, + color: event.color, + message: event.message, + payload: event.payload, + }); + }; + const adapter = getServerAdapter(agent.adapterType); const localAgentJwtScope = issueRef?.workMode === "skill_test" @@ -13089,6 +13103,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) runtimeMcp, onLog, onMeta: onAdapterMeta, + onEvent: onAdapterEvent, onRuntimeProgress: async (progress) => { await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId); },