fix(codex): let sandbox runs use the sandbox image's own Codex login (#10582)

## 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
This commit is contained in:
Devin Foley 2026-07-31 18:36:36 -07:00 committed by GitHub
parent 7301fae942
commit c0b875c46c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 412 additions and 27 deletions

View File

@ -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.$$"

View File

@ -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);

View File

@ -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<Record<string, unknown>>()),
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<typeof assertCodexCredentialsLaunchable>[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();
});
});

View File

@ -291,13 +291,21 @@ function managedMcpGatewaysFromContext(context: Record<string, unknown>): Manage
type ResolvedExecutionTarget = ReturnType<typeof readAdapterExecutionTarget>;
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<boolean> {
}): Promise<SandboxCodexAuthProbeResult> {
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<boolean> {
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<void> {
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<AdapterExec
const effectiveCodexHome = configuredCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
// Never launch a managed CODEX_HOME with no credentials. Without auth.json and
// with OPENAI_API_KEY="" the provider rejects every request with
// Never launch a managed CODEX_HOME with no credentials. Without auth.json
// and with OPENAI_API_KEY="" the provider rejects every request with
// "401 Missing bearer"; fail fast with a clear adapter error instead of
// emitting unauthenticated calls. External overrides manage their own auth.
// This is the execute-time backstop for the control plane's pre-dispatch
// configuration-incomplete gate (see server heartbeat) — both decide
// readiness through the same `evaluateCodexCredentialReadiness` predicate, so
// they cannot drift.
const credentialReadiness = await evaluateCodexCredentialReadiness({
env: process.env,
// configuration-incomplete gate (see server heartbeat); both decide host
// readiness through the same `evaluateCodexCredentialReadiness` predicate,
// and sandbox targets are additionally allowed to supply their own login
// (the pre-dispatch gate defers sandbox-destined runs here for exactly that
// probe).
await assertCodexCredentialsLaunchable({
runId,
companyId: agent.companyId,
configuredCodexHome,
configuredApiKey: configuredOpenAiApiKey,
effectiveCodexHome,
target: executionTarget,
cwd,
onLog,
});
if (credentialReadiness.managed && !credentialReadiness.ready) {
throw new Error(
`no Codex credentials provisioned for managed home "${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.`,
);
}
// Merge custom model providers (PAPERCLIP_CODEX_PROVIDERS) into the managed
// CODEX_HOME's config.toml BEFORE the home is shipped to a remote execution
// target, so both local and sandboxed Codex processes pick up the routing.

View File

@ -519,6 +519,35 @@ describe("resolveExecutionRunAdapterConfig codex_local credential pre-dispatch g
return { root, managedAgentHome };
}
it("does not gate sandbox-destined runs on host credentials — the sandbox image may carry its own login", async () => {
const { managedAgentHome } = await stubManagedCodexEnv({ seedSharedAuth: false });
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } },
secretKeys: new Set<string>(),
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({

View File

@ -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<string, unknown>;
@ -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,