Files
PaperClipAI/packages/adapters/codex-local/src/server/codex-auth-merge.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

765 lines
28 KiB
TypeScript

import { execFile as execFileCallback } from "node:child_process";
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
prepareSandboxManagedRuntime,
type SandboxManagedRuntimeClient,
type SandboxSyncOperation,
} from "@paperclipai/adapter-utils/sandbox-managed-runtime";
import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js";
const execFile = promisify(execFileCallback);
describe("codex home auth merge on sandbox asset extract", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
function subscriptionAuth(input: {
accountId: string;
lastRefresh?: string;
marker: string;
}): string {
return JSON.stringify({
tokens: {
id_token: `id-token-${input.marker}`,
access_token: `access-token-${input.marker}`,
refresh_token: `refresh-token-${input.marker}`,
account_id: input.accountId,
},
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
}, null, 2);
}
function apiKeyAuth(marker: string): string {
return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` }, null, 2);
}
async function runCodexHomeAssetExtract(input: {
sandboxAuth?: string;
hostAuth?: string;
imageAuth?: string;
}): Promise<{
commandText: string;
writtenPaths: string[];
finalAuth: string;
finalMode: number;
combinedOutput: string;
}> {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-merge-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const localHomeDir = path.join(rootDir, "local-codex-home");
const remoteHomeDir = path.join(remoteWorkspaceDir, ".paperclip-runtime", "codex", "home");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(localHomeDir, { recursive: true });
await mkdir(remoteHomeDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8");
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");
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[] = [];
const writtenPaths: string[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
writtenPaths.push(remotePath);
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
commands.push(command);
const result = await execFile("sh", ["-c", command], {
maxBuffer: 32 * 1024 * 1024,
env: { ...process.env, HOME: imageHomeDir },
});
outputs.push(result.stdout, result.stderr);
},
};
// Non-native base64-tar fallback `syncIn`: place each file mapping via
// `writeFile`, then run the operation's ordered `postUploadCommands` — the
// same seam the command-managed client provides in production. The Codex home
// asset uploads its tar + the two merge scripts, then runs the auth-merge
// command as the operation's post-upload command.
client.syncIn = async (operations) => {
for (const operation of operations) {
for (const mapping of operation.files) {
const bytes = await readFile(mapping.sourcePath);
await mkdir(path.dirname(mapping.targetPath), { recursive: true });
await client.writeFile(mapping.targetPath, bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer);
}
for (const command of operation.postUploadCommands ?? []) {
await client.run(command.command, { timeoutMs: 30_000 });
}
}
return { operations: [] };
};
await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "codex",
client,
workspaceLocalDir: localWorkspaceDir,
assets: [{
key: "home",
localDir: localHomeDir,
followSymlinks: true,
// The Codex inbound auth-merge rides the generic per-asset `provision`
// seam. This matrix drives the sandbox core directly, supplying the same
// contribution the codex adapter (`execute.ts`) attaches in production —
// proving the seam reproduces inbound behavior.
provision: buildCodexAuthInboundProvision(),
}],
});
const commandText = commands.find((command) => command.includes("codex-auth-merge-extract.sh")) ?? "";
const finalAuthPath = path.join(remoteHomeDir, "auth.json");
return {
commandText,
writtenPaths,
finalAuth: await readFile(finalAuthPath, "utf8"),
finalMode: (await lstat(finalAuthPath)).mode & 0o777,
combinedOutput: outputs.join("\n"),
};
}
it("keeps a newer same-account sandbox auth.json and installs it atomically with mode 0600", async () => {
const sandboxAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "sandbox-newer-SENTINEL",
});
const hostAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T01:00:00Z",
marker: "host-older-SENTINEL",
});
const result = await runCodexHomeAssetExtract({ sandboxAuth, hostAuth });
expect(result.finalAuth).toBe(sandboxAuth);
expect(result.finalMode).toBe(0o600);
expect(result.combinedOutput).not.toContain("SENTINEL");
expect(result.commandText).not.toContain("SENTINEL");
expect(result.commandText).toContain("codex-auth-merge-extract.sh");
expect(result.commandText).not.toContain("paperclip-extract");
expect(result.commandText).not.toContain("node -");
expect(result.commandText).not.toContain("target_tmp=");
expect(result.commandText).not.toContain("mv -f");
expect(result.writtenPaths.some((entry) => entry.endsWith("codex-auth-merge-extract.sh"))).toBe(true);
expect(result.writtenPaths.some((entry) => entry.endsWith("codex-auth-merge-decision.cjs"))).toBe(true);
});
it("installs same-account host auth when host last_refresh is strictly newer", async () => {
const sandboxAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T01:00:00Z",
marker: "sandbox-older",
});
const hostAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "host-newer",
});
const result = await runCodexHomeAssetExtract({ sandboxAuth, hostAuth });
expect(result.finalAuth).toBe(hostAuth);
expect(result.finalMode).toBe(0o600);
});
it("installs host auth on identity mismatch, auth-mode mismatch, apikey mode, and unusable sandbox auth", async () => {
const cases = [
{
name: "identity mismatch",
sandboxAuth: subscriptionAuth({
accountId: "acct-b",
lastRefresh: "2026-07-09T03:00:00Z",
marker: "sandbox-account-b",
}),
hostAuth: subscriptionAuth({
accountId: "acct-a",
lastRefresh: "2026-07-09T01:00:00Z",
marker: "host-account-a",
}),
},
{
name: "auth-mode mismatch",
sandboxAuth: subscriptionAuth({
accountId: "acct-a",
lastRefresh: "2026-07-09T03:00:00Z",
marker: "sandbox-subscription",
}),
hostAuth: apiKeyAuth("host-api-key"),
},
{
name: "both apikey",
sandboxAuth: apiKeyAuth("sandbox-api-key"),
hostAuth: apiKeyAuth("host-api-key"),
},
{
name: "sandbox account id missing",
sandboxAuth: JSON.stringify({
tokens: {
id_token: "id-token-sandbox",
access_token: "access-token-sandbox",
refresh_token: "refresh-token-sandbox",
},
last_refresh: "2026-07-09T03:00:00Z",
}),
hostAuth: subscriptionAuth({
accountId: "acct-a",
lastRefresh: "2026-07-09T01:00:00Z",
marker: "host-account-a",
}),
},
];
for (const entry of cases) {
const result = await runCodexHomeAssetExtract({
sandboxAuth: entry.sandboxAuth,
hostAuth: entry.hostAuth,
});
expect(result.finalAuth, entry.name).toBe(entry.hostAuth);
expect(result.finalMode, entry.name).toBe(0o600);
}
});
it("keeps host auth when the sandbox copy is not strictly newer (equal, missing, or unparseable freshness)", async () => {
// The extract path stages the sandbox copy as `source` and the host copy as
// `destination`. The decision predicate only adopts the source when it is
// strictly fresher, so a tie, a missing last_refresh on either side, or an
// unparseable stamp all fall through to the host destination.
const cases = [
{
name: "equal last_refresh",
sandboxAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "sandbox-equal",
}),
hostAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "host-equal",
}),
},
{
name: "missing sandbox last_refresh",
sandboxAuth: subscriptionAuth({
accountId: "acct-same",
marker: "sandbox-missing-refresh",
}),
hostAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "host-refresh",
}),
},
{
name: "missing host last_refresh",
sandboxAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "sandbox-refresh",
}),
hostAuth: subscriptionAuth({
accountId: "acct-same",
marker: "host-missing-refresh",
}),
},
{
name: "unparseable sandbox last_refresh",
sandboxAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "not-a-date",
marker: "sandbox-bad-refresh",
}),
hostAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "host-refresh",
}),
},
{
name: "unparseable host last_refresh",
sandboxAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "sandbox-refresh",
}),
hostAuth: subscriptionAuth({
accountId: "acct-same",
lastRefresh: "not-a-date",
marker: "host-bad-refresh",
}),
},
];
for (const entry of cases) {
const result = await runCodexHomeAssetExtract({
sandboxAuth: entry.sandboxAuth,
hostAuth: entry.hostAuth,
});
expect(result.finalAuth, entry.name).toBe(entry.hostAuth);
expect(result.finalMode, entry.name).toBe(0o600);
}
});
it("installs unusable host auth instead of serving leftover sandbox auth", async () => {
const sandboxAuth = subscriptionAuth({
accountId: "acct-a",
lastRefresh: "2026-07-09T03:00:00Z",
marker: "sandbox-valid-SENTINEL",
});
const cases = [
{
name: "invalid JSON",
hostAuth: "{not valid json",
},
{
name: "partial subscription",
hostAuth: JSON.stringify({
tokens: {
account_id: "acct-b",
},
last_refresh: "2026-07-09T02:00:00Z",
}),
},
{
name: "top-level access token",
hostAuth: JSON.stringify({
access_token: "top-level-parser-differential-token",
}),
},
];
for (const entry of cases) {
const result = await runCodexHomeAssetExtract({
sandboxAuth,
hostAuth: entry.hostAuth,
});
expect(result.finalAuth, entry.name).toBe(entry.hostAuth);
expect(result.finalAuth, entry.name).not.toBe(sandboxAuth);
expect(result.finalMode, entry.name).toBe(0o600);
expect(result.combinedOutput, entry.name).not.toContain("SENTINEL");
expect(result.commandText, entry.name).not.toContain("SENTINEL");
}
});
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);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const localHomeDir = path.join(rootDir, "local-codex-home");
const remoteHomeDir = path.join(remoteWorkspaceDir, ".paperclip-runtime", "codex", "home");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(localHomeDir, { recursive: true });
await mkdir(remoteHomeDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8");
// Host credential is strictly newer → wins the merge (same identity).
const hostAuth = subscriptionAuth({
accountId: "acct-native",
lastRefresh: "2026-07-10T02:00:00Z",
marker: "host-newer-SENTINEL",
});
const sandboxAuth = subscriptionAuth({
accountId: "acct-native",
lastRefresh: "2026-07-10T01:00:00Z",
marker: "sandbox-older-SENTINEL",
});
await writeFile(path.join(localHomeDir, "auth.json"), hostAuth, { mode: 0o600 });
await writeFile(path.join(localHomeDir, "config.toml"), "model = \"gpt\"\n", "utf8");
await writeFile(path.join(remoteHomeDir, "auth.json"), sandboxAuth, { mode: 0o600 });
// A native runner: the orchestrator must make NO direct writeFile/run — every
// byte (incl. auth.json) rides `syncIn` (native uploadFiles), and the merge
// runs as the operation's ordered post-upload command.
const directWrites: string[] = [];
const directRuns: string[] = [];
const captured: SandboxSyncOperation[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
directWrites.push(remotePath);
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
directRuns.push(command);
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
client.syncIn = async (operations) => {
for (const operation of operations) {
captured.push(operation);
for (const mapping of operation.files) {
const bytes = await readFile(mapping.sourcePath);
await mkdir(path.dirname(mapping.targetPath), { recursive: true });
await writeFile(mapping.targetPath, bytes);
if (mapping.mode != null) await lstat(mapping.targetPath);
}
for (const command of operation.postUploadCommands ?? []) {
await execFile("sh", ["-c", command.command], { maxBuffer: 32 * 1024 * 1024 });
}
}
return { operations: [] };
};
await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "codex",
client,
workspaceLocalDir: localWorkspaceDir,
assets: [{
key: "home",
localDir: localHomeDir,
followSymlinks: true,
provision: buildCodexAuthInboundProvision(),
}],
});
// 0 direct exec/writeFile — pure native delegation.
expect(directWrites).toEqual([]);
expect(directRuns).toEqual([]);
// One operation carries the home asset: the home tar + the two merge scripts
// as `files` mappings, and the auth-merge as the ordered post-upload command.
const homeOp = captured.find((op) =>
op.files.some((mapping) => mapping.targetPath.endsWith("home-upload.tar")),
);
expect(homeOp).toBeDefined();
const targets = homeOp!.files.map((mapping) => path.posix.basename(mapping.targetPath)).sort();
expect(targets).toEqual([
"codex-auth-merge-decision.cjs",
"codex-auth-merge-extract.sh",
"home-upload.tar",
]);
expect(homeOp!.files.every((mapping) => mapping.kind === "file")).toBe(true);
expect(homeOp!.postUploadCommands).toHaveLength(1);
// The post-command is the auth-merge script, NOT a plain `tar -xf` (C6).
const mergeCommand = homeOp!.postUploadCommands![0].command;
expect(mergeCommand).toContain("codex-auth-merge-extract.sh");
expect(mergeCommand).not.toMatch(/^\s*tar -xf/);
// C5: no token/credential material leaks into the operation metadata.
const opJson = JSON.stringify(captured);
expect(opJson).not.toContain("SENTINEL");
expect(opJson).not.toContain("refresh-token");
// C6: newer host credential won, installed atomically at mode 0600.
const finalAuthPath = path.join(remoteHomeDir, "auth.json");
expect(await readFile(finalAuthPath, "utf8")).toBe(hostAuth);
expect((await lstat(finalAuthPath)).mode & 0o777).toBe(0o600);
});
});
// The extract shell script consumes the decision predicate as a child process
// and only branches on its exit code (10 = use the source auth.json, 20 = keep
// the destination auth.json). The predicate is direction-agnostic: the caller
// decides which file is `source` and which is `destination` by argument order
// (first = source, second = destination), so there is no `--direction` flag.
// This suite drives the `.cjs` directly the same way, asserting the exit code
// per row. Both the inbound restore (extract.sh: source = the sandbox copy,
// destination = the host copy) and the future outbound copy-back guard reduce to
// the same single question — adopt the source only when it is strictly newer,
// same-identity, subscription-kind.
describe("codex-auth-merge-decision predicate (source/destination)", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
const decisionScriptPath = fileURLToPath(
new URL("./codex-auth-merge-decision.cjs", import.meta.url),
);
const KEEP_DESTINATION = 20;
const USE_SOURCE = 10;
function subscriptionAuth(input: {
accountId: string;
lastRefresh?: string;
marker?: string;
}): string {
const suffix = input.marker ?? input.accountId;
return JSON.stringify({
tokens: {
id_token: `id-token-${suffix}`,
access_token: `access-token-${suffix}`,
refresh_token: `refresh-token-${suffix}`,
account_id: input.accountId,
},
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
});
}
function apiKeyAuth(marker: string): string {
return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` });
}
async function runDecision(input: {
sourceAuth: string;
destinationAuth: string;
}): Promise<{ code: number; output: string }> {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-decision-"));
cleanupDirs.push(dir);
const sourcePath = path.join(dir, "source-auth.json");
const destinationPath = path.join(dir, "destination-auth.json");
await writeFile(sourcePath, input.sourceAuth, { mode: 0o600 });
await writeFile(destinationPath, input.destinationAuth, { mode: 0o600 });
// Arg order is the whole contract: first = source, second = destination.
const args = [decisionScriptPath, sourcePath, destinationPath];
try {
const result = await execFile("node", args);
return { code: 0, output: `${result.stdout}\n${result.stderr}` };
} catch (error) {
const failure = error as { code?: unknown; stdout?: string; stderr?: string };
const output = `${failure.stdout ?? ""}\n${failure.stderr ?? ""}`;
if (typeof failure.code === "number") return { code: failure.code, output };
throw error;
}
}
const NEWER = "2026-07-09T02:00:00Z";
const OLDER = "2026-07-09T01:00:00Z";
const cases: { name: string; sourceAuth: string; destinationAuth: string; expected: number }[] = [
{
name: "source strictly newer, same identity → use source",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
expected: USE_SOURCE,
},
{
name: "equal last_refresh (tie) → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER, marker: "src" }),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER, marker: "dst" }),
expected: KEEP_DESTINATION,
},
{
name: "source older → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
expected: KEEP_DESTINATION,
},
{
name: "identity mismatch even when source newer → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct-source", lastRefresh: NEWER }),
destinationAuth: subscriptionAuth({ accountId: "acct-destination", lastRefresh: OLDER }),
expected: KEEP_DESTINATION,
},
{
name: "source last_refresh missing → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct" }),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
expected: KEEP_DESTINATION,
},
{
name: "destination last_refresh missing → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
destinationAuth: subscriptionAuth({ accountId: "acct" }),
expected: KEEP_DESTINATION,
},
{
name: "both last_refresh missing → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct" }),
destinationAuth: subscriptionAuth({ accountId: "acct" }),
expected: KEEP_DESTINATION,
},
{
name: "source last_refresh unparseable → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: "not-a-date" }),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
expected: KEEP_DESTINATION,
},
{
name: "destination last_refresh unparseable → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: "not-a-date" }),
expected: KEEP_DESTINATION,
},
{
name: "source apikey → keep destination",
sourceAuth: apiKeyAuth("source"),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
expected: KEEP_DESTINATION,
},
{
name: "destination apikey → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
destinationAuth: apiKeyAuth("destination"),
expected: KEEP_DESTINATION,
},
{
name: "both apikey → keep destination",
sourceAuth: apiKeyAuth("source"),
destinationAuth: apiKeyAuth("destination"),
expected: KEEP_DESTINATION,
},
{
name: "kind mismatch (source subscription, destination apikey) → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
destinationAuth: apiKeyAuth("destination"),
expected: KEEP_DESTINATION,
},
{
name: "source unusable JSON → keep destination",
sourceAuth: "{not valid json",
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
expected: KEEP_DESTINATION,
},
{
name: "destination unusable JSON → keep destination",
sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }),
destinationAuth: "{not valid json",
expected: KEEP_DESTINATION,
},
];
for (const entry of cases) {
it(entry.name, async () => {
const result = await runDecision({
sourceAuth: entry.sourceAuth,
destinationAuth: entry.destinationAuth,
});
expect(result.code).toBe(entry.expected);
});
}
it("never emits source token bytes", async () => {
const result = await runDecision({
sourceAuth: subscriptionAuth({
accountId: "acct",
lastRefresh: NEWER,
marker: "SECRET-SENTINEL",
}),
destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER }),
});
expect(result.code).toBe(USE_SOURCE);
expect(result.output).not.toContain("SENTINEL");
});
});