diff --git a/packages/adapters/codex-local/src/server/codex-auth-merge-extract.sh b/packages/adapters/codex-local/src/server/codex-auth-merge-extract.sh index 37dbe60c63..fdb1be03f4 100755 --- a/packages/adapters/codex-local/src/server/codex-auth-merge-extract.sh +++ b/packages/adapters/codex-local/src/server/codex-auth-merge-extract.sh @@ -56,6 +56,15 @@ if [ "$keep_sandbox" -eq 1 ] && [ -f "$preserve_auth" ]; then source_auth="$preserve_auth" fi +# When neither the shipped host home nor a preserved prior-lease credential +# provides auth.json, fall back to the sandbox image's own Codex login so an +# image signed in to Codex works on hosts with no credentials (managed cloud +# hosts never have one). Host and preserved credentials always win over the +# image login, preserving the existing shadowing precedence. +if [ ! -f "$source_auth" ] && [ -f "${HOME:-/nonexistent}/.codex/$auth_name" ]; then + source_auth="${HOME:-/nonexistent}/.codex/$auth_name" +fi + if [ -f "$source_auth" ]; then target_auth="$asset_dir/$auth_name" target_tmp="$asset_dir/.auth.json.paperclip.$$" diff --git a/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts b/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts index a1d4372c9d..502c069e94 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts @@ -47,8 +47,9 @@ describe("codex home auth merge on sandbox asset extract", () => { } async function runCodexHomeAssetExtract(input: { - sandboxAuth: string; - hostAuth: string; + sandboxAuth?: string; + hostAuth?: string; + imageAuth?: string; }): Promise<{ commandText: string; writtenPaths: string[]; @@ -67,9 +68,19 @@ describe("codex home auth merge on sandbox asset extract", () => { await mkdir(localHomeDir, { recursive: true }); await mkdir(remoteHomeDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8"); - await writeFile(path.join(localHomeDir, "auth.json"), input.hostAuth, { mode: 0o600 }); + if (input.hostAuth !== undefined) { + await writeFile(path.join(localHomeDir, "auth.json"), input.hostAuth, { mode: 0o600 }); + } await writeFile(path.join(localHomeDir, "config.toml"), "model = \"gpt\"\n", "utf8"); - await writeFile(path.join(remoteHomeDir, "auth.json"), input.sandboxAuth, { mode: 0o600 }); + if (input.sandboxAuth !== undefined) { + await writeFile(path.join(remoteHomeDir, "auth.json"), input.sandboxAuth, { mode: 0o600 }); + } + // A fake in-sandbox $HOME whose ~/.codex may carry the image's own login. + const imageHomeDir = path.join(rootDir, "image-home"); + await mkdir(path.join(imageHomeDir, ".codex"), { recursive: true }); + if (input.imageAuth !== undefined) { + await writeFile(path.join(imageHomeDir, ".codex", "auth.json"), input.imageAuth, { mode: 0o600 }); + } const commands: string[] = []; const outputs: string[] = []; @@ -90,7 +101,10 @@ describe("codex home auth merge on sandbox asset extract", () => { }, run: async (command) => { commands.push(command); - const result = await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + const result = await execFile("sh", ["-c", command], { + maxBuffer: 32 * 1024 * 1024, + env: { ...process.env, HOME: imageHomeDir }, + }); outputs.push(result.stdout, result.stderr); }, }; @@ -375,6 +389,64 @@ describe("codex home auth merge on sandbox asset extract", () => { } }); + it("falls back to the sandbox image's own login when neither host nor prior asset has auth", async () => { + const imageAuth = subscriptionAuth({ + accountId: "acct-image", + lastRefresh: "2026-07-01T00:00:00Z", + marker: "image", + }); + const result = await runCodexHomeAssetExtract({ + imageAuth, + }); + + expect(result.finalAuth).toBe(imageAuth); + expect(result.finalMode).toBe(0o600); + }); + + it("prefers shipped host auth over the image's own login", async () => { + const hostAuth = subscriptionAuth({ + accountId: "acct-host", + lastRefresh: "2026-07-02T00:00:00Z", + marker: "host", + }); + const imageAuth = subscriptionAuth({ + accountId: "acct-image", + lastRefresh: "2026-07-03T00:00:00Z", + marker: "image", + }); + const result = await runCodexHomeAssetExtract({ + hostAuth, + imageAuth, + }); + + expect(result.finalAuth).toBe(hostAuth); + }); + + it("prefers a preserved newer prior-lease credential over the image's own login", async () => { + const hostAuth = subscriptionAuth({ + accountId: "acct-1", + lastRefresh: "2026-07-01T00:00:00Z", + marker: "host", + }); + const sandboxAuth = subscriptionAuth({ + accountId: "acct-1", + lastRefresh: "2026-07-05T00:00:00Z", + marker: "prior-lease", + }); + const imageAuth = subscriptionAuth({ + accountId: "acct-image", + lastRefresh: "2026-07-06T00:00:00Z", + marker: "image", + }); + const result = await runCodexHomeAssetExtract({ + hostAuth, + sandboxAuth, + imageAuth, + }); + + expect(result.finalAuth).toBe(sandboxAuth); + }); + it("routes the Codex home asset through a single native syncIn operation whose post-command is the auth-merge (#4, C5/C6)", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-native-route-")); cleanupDirs.push(rootDir); diff --git a/packages/adapters/codex-local/src/server/execute.auth.test.ts b/packages/adapters/codex-local/src/server/execute.auth.test.ts index 2140c0dfc5..1d4cd8f143 100644 --- a/packages/adapters/codex-local/src/server/execute.auth.test.ts +++ b/packages/adapters/codex-local/src/server/execute.auth.test.ts @@ -2,7 +2,15 @@ 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 { execute } from "./execute.js"; + +const mockRunTargetShellCommand = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => ({ + ...(await importOriginal>()), + runAdapterExecutionTargetShellCommand: mockRunTargetShellCommand, +})); + +import { assertCodexCredentialsLaunchable, execute } from "./execute.js"; describe("codex managed-home auth fail-fast", () => { const cleanupDirs: string[] = []; @@ -76,3 +84,174 @@ describe("codex managed-home auth fail-fast", () => { await expect(fs.access(path.join(managedAgentHome, "auth.json"))).rejects.toBeTruthy(); }); }); + +describe("codex sandbox-target credential gate", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + mockRunTargetShellCommand.mockReset(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + async function makeCredentiallessManagedHome() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-sandbox-gate-")); + cleanupDirs.push(root); + const paperclipHome = path.join(root, "paperclip-home"); + const emptySharedHome = path.join(root, "shared-codex-home"); + await fs.mkdir(emptySharedHome, { recursive: true }); + const managedAgentHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "agents", + "agent-1", + "codex-home", + ); + const env = { + PAPERCLIP_HOME: paperclipHome, + PAPERCLIP_INSTANCE_ID: "default", + CODEX_HOME: emptySharedHome, + } as NodeJS.ProcessEnv; + return { env, managedAgentHome }; + } + + const sandboxTarget = { + kind: "remote", + transport: "sandbox", + } as unknown as Parameters[0]["target"]; + + it("launches against a sandbox that carries its own Codex login", async () => { + const { env, managedAgentHome } = await makeCredentiallessManagedHome(); + mockRunTargetShellCommand.mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "" }); + const logs: string[] = []; + + await expect( + assertCodexCredentialsLaunchable({ + runId: "run-sandbox-login", + companyId: "company-1", + configuredCodexHome: managedAgentHome, + configuredApiKey: null, + effectiveCodexHome: managedAgentHome, + target: sandboxTarget, + cwd: "/workspace", + env, + onLog: async (_stream, line) => { + logs.push(line); + }, + }), + ).resolves.toBeUndefined(); + + expect(mockRunTargetShellCommand).toHaveBeenCalledWith( + "run-sandbox-login", + sandboxTarget, + 'test -f "$HOME/.codex/auth.json"', + expect.objectContaining({ cwd: "/workspace" }), + ); + expect(logs.join("")).toContain("sandbox's own Codex login"); + }); + + it("fails a sandbox run when neither the host nor the sandbox has credentials", async () => { + const { env, managedAgentHome } = await makeCredentiallessManagedHome(); + mockRunTargetShellCommand.mockResolvedValue({ exitCode: 1, timedOut: false, stdout: "", stderr: "" }); + + await expect( + assertCodexCredentialsLaunchable({ + runId: "run-sandbox-nologin", + companyId: "company-1", + configuredCodexHome: managedAgentHome, + configuredApiKey: null, + effectiveCodexHome: managedAgentHome, + target: sandboxTarget, + cwd: "/workspace", + env, + onLog: async () => {}, + }), + ).rejects.toThrow(/the sandbox has no Codex login/); + }); + + it("proceeds with a warning when the sandbox login probe fails operationally", async () => { + const { env, managedAgentHome } = await makeCredentiallessManagedHome(); + mockRunTargetShellCommand.mockRejectedValue(new Error("transport lost")); + const stderrLines: string[] = []; + + await expect( + assertCodexCredentialsLaunchable({ + runId: "run-probe-error", + companyId: "company-1", + configuredCodexHome: managedAgentHome, + configuredApiKey: null, + effectiveCodexHome: managedAgentHome, + target: sandboxTarget, + cwd: "/workspace", + env, + onLog: async (stream, line) => { + if (stream === "stderr") stderrLines.push(line); + }, + }), + ).resolves.toBeUndefined(); + expect(stderrLines.join("")).toContain("Could not verify the sandbox's Codex login"); + }); + + it("treats a probe timeout as unverifiable, not as a missing credential", async () => { + const { env, managedAgentHome } = await makeCredentiallessManagedHome(); + mockRunTargetShellCommand.mockResolvedValue({ exitCode: 0, timedOut: true, stdout: "", stderr: "" }); + + await expect( + assertCodexCredentialsLaunchable({ + runId: "run-probe-timeout", + companyId: "company-1", + configuredCodexHome: managedAgentHome, + configuredApiKey: null, + effectiveCodexHome: managedAgentHome, + target: sandboxTarget, + cwd: "/workspace", + env, + onLog: async () => {}, + }), + ).resolves.toBeUndefined(); + }); + + it("keeps the strict host requirement for non-sandbox targets", async () => { + const { env, managedAgentHome } = await makeCredentiallessManagedHome(); + + await expect( + assertCodexCredentialsLaunchable({ + runId: "run-local", + companyId: "company-1", + configuredCodexHome: managedAgentHome, + configuredApiKey: null, + effectiveCodexHome: managedAgentHome, + target: undefined, + cwd: "/workspace", + env, + onLog: async () => {}, + }), + ).rejects.toThrow(/Sign in to Codex on the host/); + expect(mockRunTargetShellCommand).not.toHaveBeenCalled(); + }); + + it("does not gate at all when a per-agent OPENAI_API_KEY is configured", async () => { + const { env, managedAgentHome } = await makeCredentiallessManagedHome(); + + await expect( + assertCodexCredentialsLaunchable({ + runId: "run-api-key", + companyId: "company-1", + configuredCodexHome: managedAgentHome, + configuredApiKey: "sk-agent", + effectiveCodexHome: managedAgentHome, + target: sandboxTarget, + cwd: "/workspace", + env, + onLog: async () => {}, + }), + ).resolves.toBeUndefined(); + expect(mockRunTargetShellCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 966f681583..10d4e83f73 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -291,13 +291,21 @@ function managedMcpGatewaysFromContext(context: Record): Manage type ResolvedExecutionTarget = ReturnType; type MaybeResolvedExecutionTarget = ResolvedExecutionTarget | undefined; -async function sandboxCodexAuthJsonExists(input: { +type SandboxCodexAuthProbeResult = "present" | "absent" | "unknown"; + +/** + * Probe the sandbox for its own `~/.codex/auth.json`. "unknown" means the + * probe itself failed (timeout, transport error, or a shell failure other + * than `test`'s clean false) — callers that gate on the result must not + * report that as a missing credential. + */ +async function probeSandboxCodexAuthJson(input: { runId: string; target: MaybeResolvedExecutionTarget; cwd: string; -}): Promise { +}): Promise { if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { - return false; + return "absent"; } try { @@ -311,12 +319,94 @@ async function sandboxCodexAuthJsonExists(input: { timeoutSec: 5, }, ); - return !result.timedOut && result.exitCode === 0; + if (result.timedOut) return "unknown"; + if (result.exitCode === 0) return "present"; + return result.exitCode === 1 ? "absent" : "unknown"; } catch { - return false; + return "unknown"; } } +async function sandboxCodexAuthJsonExists(input: { + runId: string; + target: MaybeResolvedExecutionTarget; + cwd: string; +}): Promise { + return (await probeSandboxCodexAuthJson(input)) === "present"; +} + +/** + * Execute-time credential gate. A managed home with no host-side credentials + * is still launchable when the run targets a sandbox whose image carries its + * own Codex login (`~/.codex/auth.json` baked in during image setup): the + * inbound auth merge ships the credential-less host home and keeps the + * sandbox's credential, so the host is not a required credential source — + * on managed cloud hosts a local Codex login never exists at all. The + * sandbox is probed before the run is declared unlaunchable; non-sandbox + * targets keep the strict host-side requirement. + */ +export async function assertCodexCredentialsLaunchable(input: { + runId: string; + companyId: string; + configuredCodexHome: string | null; + configuredApiKey: string | null; + effectiveCodexHome: string; + target: MaybeResolvedExecutionTarget; + cwd: string; + env?: NodeJS.ProcessEnv; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const credentialReadiness = await evaluateCodexCredentialReadiness({ + env: input.env ?? process.env, + companyId: input.companyId, + configuredCodexHome: input.configuredCodexHome, + configuredApiKey: input.configuredApiKey, + }); + if (!credentialReadiness.managed || credentialReadiness.ready) return; + + const targetIsSandbox = + input.target?.kind === "remote" && input.target.transport === "sandbox"; + if (targetIsSandbox) { + const sandboxAuthJson = await probeSandboxCodexAuthJson({ + runId: input.runId, + target: input.target, + cwd: input.cwd, + }); + if (sandboxAuthJson === "present") { + await input.onLog( + "stdout", + `Using the sandbox's own Codex login; managed home "${input.effectiveCodexHome}" has no host credentials.\n`, + ); + return; + } + if (sandboxAuthJson === "unknown") { + // The probe failing is an operational problem, not evidence that the + // sandbox lacks a login — proceeding lets a genuinely credentialed + // sandbox run, and a credential-less one still fails at Codex's first + // request with the provider's own error. + await input.onLog( + "stderr", + `Could not verify the sandbox's Codex login (probe failed); proceeding. ` + + `If the sandbox has no credentials, Codex will fail at its first request.\n`, + ); + return; + } + throw new Error( + `no Codex credentials provisioned for managed home "${input.effectiveCodexHome}" ` + + `(no usable auth.json, OPENAI_API_KEY is empty, and the sandbox has no Codex login). ` + + `Use a sandbox image that is signed in to Codex, configure a per-agent OPENAI_API_KEY, ` + + `or sign in to Codex on the host with a ChatGPT subscription.`, + ); + } + + throw new Error( + `no Codex credentials provisioned for managed home "${input.effectiveCodexHome}" ` + + `(no usable auth.json and OPENAI_API_KEY is empty). ` + + `Sign in to Codex on the host with a ChatGPT subscription, or configure a per-agent ` + + `OPENAI_API_KEY.`, + ); +} + async function emitSandboxAuthPrecedenceWarningIfNeeded(input: { runId: string; target: MaybeResolvedExecutionTarget; @@ -542,28 +632,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const { managedAgentHome } = await stubManagedCodexEnv({ seedSharedAuth: false }); + const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ + config: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } }, + secretKeys: new Set(), + manifest: [], + }); + + await expect( + resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + adapterType: "codex_local", + issueId: "issue-1", + responsibleUserId: "user-1", + environmentDriver: "sandbox", + executionRunConfig: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } }, + projectEnv: null, + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings: vi.fn(), + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }), + ).resolves.toMatchObject({ + resolvedConfig: expect.objectContaining({ command: "codex" }), + }); + }); + it("surfaces a configuration-incomplete blocker when a managed home has no auth and OPENAI_API_KEY is empty", async () => { const { managedAgentHome } = await stubManagedCodexEnv({ seedSharedAuth: false }); const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 11ee85e847..79b0db662f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -702,6 +702,7 @@ export async function resolveExecutionRunAdapterConfig(input: { responsibleUserId?: string | null; environmentId?: string | null; environmentEnv?: unknown; + environmentDriver?: string | null; projectId?: string | null; routineId?: string | null; executionRunConfig: Record; @@ -976,7 +977,13 @@ export async function resolveExecutionRunAdapterConfig(input: { // resolution so a per-agent OPENAI_API_KEY (plain or resolved secret) counts // as satisfying the credential. It shares the exact readiness predicate the // adapter uses at execute time, so the two cannot drift. - if ((input.adapterType ?? null) === "codex_local") { + // + // Sandbox-destined runs are exempt: the sandbox image may carry its own + // Codex login (`~/.codex/auth.json` baked in at image setup), which only the + // adapter can probe once the sandbox is up — and on managed cloud hosts a + // host-side login never exists at all. The adapter's execute-time gate + // remains the authority there; it probes the sandbox before failing. + if ((input.adapterType ?? null) === "codex_local" && (input.environmentDriver ?? null) !== "sandbox") { const resolvedEnv = parseObject(resolvedConfig.env); const readiness = await evaluateCodexCredentialReadiness({ env: process.env, @@ -13211,6 +13218,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) heartbeatRunId: run.id, environmentId: selectedEnvironmentForConfig?.id ?? null, environmentEnv: selectedEnvironmentForConfig?.envVars ?? null, + environmentDriver: selectedEnvironmentForConfig?.driver ?? null, projectId: projectContext?.id ?? null, routineId: routineEnvContext.routineId, responsibleUserId,