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:
parent
7301fae942
commit
c0b875c46c
|
|
@ -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.$$"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue