mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## 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
765 lines
28 KiB
TypeScript
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");
|
|
});
|
|
});
|