From c0b875c46cfb633674b2306be534b8fbeb0d6b92 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 31 Jul 2026 18:36:36 -0700 Subject: [PATCH] fix(codex): let sandbox runs use the sandbox image's own Codex login (#10582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Codex agents can run inside sandbox environments, and operators can bake a Codex login into the sandbox image during interactive image setup > - Two credential gates (the control plane's pre-dispatch configuration-incomplete gate and the adapter's execute-time fail-fast) required host-side Codex credentials — a usable `auth.json` in the managed home or a configured `OPENAI_API_KEY` — regardless of where the run executes > - On managed cloud hosts a local Codex login never exists, so every sandbox run of a Codex agent failed immediately with "configuration incomplete: no Codex credentials available for managed home …", even though the adapter's inbound auth merge already supports the image-login case end to end > - This pull request makes the execute-time gate probe the sandbox for its own `~/.codex/auth.json` before failing, and exempts sandbox-destined runs from the pre-dispatch host check > - The benefit is that a sandbox image signed in to Codex is a first-class credential source, matching what the auth-merge, precedence-warning, and copy-back machinery were already built for ## Linked Issues or Issue Description **What happened?** Running a `codex_local` agent in a sandbox environment whose image carries a Codex login failed instantly with `configuration incomplete: no Codex credentials available for managed home "…/codex-home". Sign in to Codex on the host with a ChatGPT subscription, or bind a per-agent OPENAI_API_KEY secret for this agent.` The host has no Codex login and never will on a managed cloud deployment; the sandbox's own login was never consulted. **Steps to reproduce** 1. Configure a sandbox environment and capture a custom image after signing in to Codex inside the interactive image setup. 2. Create a `codex_local` agent that uses that environment, on a host with no Codex login and no `OPENAI_API_KEY` bound. 3. Start a run: it fails pre-dispatch with the configuration-incomplete blocker above. **Expected behavior** The run launches and Codex authenticates with the sandbox image's own login, the same way the adapter's host↔sandbox auth merge already keeps the sandbox credential when the host ships none. A run should only fail fast when neither the host, a bound `OPENAI_API_KEY`, nor the sandbox has credentials. **Paperclip version** Current `master` (cloud image deployments). **Deployment mode** Managed cloud stacks (any deployment where the server host has no local Codex login). ## What Changed - Extracted the adapter's execute-time gate into `assertCodexCredentialsLaunchable`: when host readiness fails and the target is a sandbox, it probes `~/.codex/auth.json` in the sandbox (same command the auth-precedence warning uses) and proceeds with a log line naming the credential source; when the sandbox has no login either, the error now names all three remediation options (sandbox image sign-in, per-agent `OPENAI_API_KEY`, host sign-in). Non-sandbox targets keep today's strict behavior byte-for-byte. - The control plane's pre-dispatch gate in `resolveExecutionRunAdapterConfig` now takes the selected environment's driver and skips the host-credential check for sandbox-destined runs — only the adapter can probe the sandbox once it is up, so the execute-time gate is the authority there. Non-sandbox runs keep the early, well-attributed configuration-incomplete blocker. - The codex Test flow needed no change: it already seeds host credentials only when they exist and otherwise leaves the sandbox's `CODEX_HOME` alone; this aligns the run path with it. ## Verification - `cd packages/adapters/codex-local && pnpm vitest run` — 210 tests, including new gate cases: sandbox login present (proceeds + logs source), sandbox and host both credential-less (fails with the extended message), non-sandbox target (strict host requirement kept, no sandbox probe), per-agent API key (no probe at all). - `cd server && pnpm vitest run src/__tests__/heartbeat-project-env.test.ts src/__tests__/codex-local-adapter-environment.test.ts` — includes the new sandbox-exemption case next to the existing blocker tests. - `pnpm run typecheck` in `server` and `packages/adapters/codex-local`. ## Risks - Sandbox-destined misconfigurations (no credentials anywhere) now surface at adapter execute time instead of pre-dispatch, so they read as an adapter failure with a precise message rather than a configuration-incomplete blocker. The trade-off is deliberate: the sandbox must be up to know whether credentials exist, and the failure message names the exact remediations. - The sandbox probe adds one short (5s-capped) shell command to sandbox runs whose host has no credentials; runs with host credentials or a bound key are untouched. - Self-hosted behavior is unchanged for local and SSH targets. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — extended thinking, agentic tool use (file edits, vitest/tsc runs). No other models involved. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/server/codex-auth-merge-extract.sh | 9 + .../src/server/codex-auth-merge.test.ts | 82 +++++++- .../src/server/execute.auth.test.ts | 181 +++++++++++++++++- .../codex-local/src/server/execute.ts | 128 +++++++++++-- .../__tests__/heartbeat-project-env.test.ts | 29 +++ server/src/services/heartbeat.ts | 10 +- 6 files changed, 412 insertions(+), 27 deletions(-) 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,