Files
PaperClipAI/packages/adapters/codex-local/src/server/execute.auth.test.ts
T
Devin Foley c0b875c46c 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
2026-07-31 18:36:36 -07:00

258 lines
8.4 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
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[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("fails fast when a managed CODEX_HOME has no auth.json and OPENAI_API_KEY is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-failfast-"));
cleanupDirs.push(root);
const paperclipHome = path.join(root, "paperclip-home");
const emptySharedHome = path.join(root, "shared-codex-home");
const workspaceDir = path.join(root, "workspace");
// A managed per-agent home with no credentials seeded into it.
const managedAgentHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"agents",
"agent-1",
"codex-home",
);
await fs.mkdir(emptySharedHome, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
// Source home has no auth.json, so nothing is symlinked into the managed home.
vi.stubEnv("PAPERCLIP_HOME", paperclipHome);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
vi.stubEnv("CODEX_HOME", emptySharedHome);
await expect(
execute({
runId: "run-failfast",
agent: {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
adapterType: "codex_local",
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
engine: "cli",
command: "codex",
cwd: workspaceDir,
env: {
CODEX_HOME: managedAgentHome,
OPENAI_API_KEY: "",
},
},
context: {},
onLog: async () => {},
}),
).rejects.toThrow(/no Codex credentials provisioned for managed home/);
// The managed home must not have been left with a usable auth.json.
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();
});
});