feat(codex-local): outbound auth copy-back as home-asset restore contribution (#9788)

This commit is contained in:
Nicky Leach 2026-07-18 09:13:45 -05:00 committed by GitHub
parent 936215693c
commit 14da75dfc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 699 additions and 0 deletions

View File

@ -0,0 +1,296 @@
import { chmod, lstat, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
// The copy-back module reuses the exact same direction-agnostic decision
// predicate (`codex-auth-merge-decision.cjs`) that the inbound extract path
// runs, only with the arguments flipped: for an outbound copy-back the sandbox
// copy is the `source` and the host copy is the `destination`, so exit 10
// (use source) means "install the sandbox credential onto the host" and exit 20
// (keep destination) means "leave the host credential untouched". This suite
// drives the REAL `.cjs` through the module (no stub predicate) against a real
// host tmp filesystem, injecting only the sandbox read.
describe("copyBackCodexAuth", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
// Re-open perms in case a test tightened them, so cleanup always succeeds.
await chmod(dir, 0o700).catch(() => undefined);
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 makeHostDir(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-"));
cleanupDirs.push(dir);
return dir;
}
const NEWER = "2026-07-09T02:00:00Z";
const OLDER = "2026-07-09T01:00:00Z";
async function runCopyBack(input: {
sandboxAuth: string | (() => Promise<Buffer>);
hostAuth: string;
hostDir?: string;
}): Promise<{
outcome: Awaited<ReturnType<typeof copyBackCodexAuth>>;
finalHostAuth: string;
finalHostMode: number;
logs: string[];
leftoverEntries: string[];
}> {
const hostDir = input.hostDir ?? (await makeHostDir());
const hostAuthPath = path.join(hostDir, "auth.json");
await writeFile(hostAuthPath, input.hostAuth, { mode: 0o600 });
const readSandboxAuth =
typeof input.sandboxAuth === "function"
? input.sandboxAuth
: async () => Buffer.from(input.sandboxAuth as string, "utf8");
const logs: string[] = [];
const outcome = await copyBackCodexAuth({
readSandboxAuth,
hostAuthPath,
log: (line) => {
logs.push(line);
},
});
const finalHostAuth = await readFile(hostAuthPath, "utf8");
const finalHostMode = (await lstat(hostAuthPath)).mode & 0o777;
const leftoverEntries = (await readdir(hostDir)).filter((name) => name !== "auth.json");
return { outcome, finalHostAuth, finalHostMode, logs, leftoverEntries };
}
it("installs a strictly-newer same-account sandbox auth onto the host at 0600", async () => {
const sandboxAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: NEWER,
marker: "sandbox-newer-SENTINEL",
});
const hostAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: OLDER,
marker: "host-older-SENTINEL",
});
const result = await runCopyBack({ sandboxAuth, hostAuth });
expect(result.outcome).toBe("copied");
expect(result.finalHostAuth).toBe(sandboxAuth);
expect(result.finalHostMode).toBe(0o600);
// Temp staging file must be gone once the swap completes.
expect(result.leftoverEntries).toEqual([]);
// Never leak token bytes in log output.
expect(result.logs.join("\n")).not.toContain("SENTINEL");
});
it("keeps the host auth when the sandbox copy is not strictly newer", async () => {
const cases: { name: string; sandboxAuth: string; hostAuth: string }[] = [
{
name: "tie",
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "sandbox-tie" }),
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-tie" }),
},
{
name: "sandbox older",
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "sandbox-older" }),
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-newer" }),
},
{
name: "missing sandbox last_refresh",
sandboxAuth: subscriptionAuth({ accountId: "acct-same", marker: "sandbox-no-refresh" }),
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-refresh" }),
},
{
name: "unparseable sandbox last_refresh",
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "not-a-date", marker: "sandbox-bad" }),
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-refresh" }),
},
];
for (const entry of cases) {
const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
expect(result.outcome, entry.name).toBe("kept-host");
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
expect(result.finalHostMode, entry.name).toBe(0o600);
expect(result.leftoverEntries, entry.name).toEqual([]);
}
});
it("keeps the host auth on identity mismatch, kind mismatch, apikey, and unusable sandbox auth", async () => {
const hostAuth = subscriptionAuth({ accountId: "acct-host", lastRefresh: OLDER, marker: "host-keep" });
const cases: { name: string; sandboxAuth: string; hostAuth: string }[] = [
{
name: "identity mismatch (sandbox newer, different account)",
sandboxAuth: subscriptionAuth({ accountId: "acct-other", lastRefresh: NEWER, marker: "sandbox-other" }),
hostAuth,
},
{
name: "kind mismatch (sandbox subscription, host apikey)",
sandboxAuth: subscriptionAuth({ accountId: "acct-host", lastRefresh: NEWER, marker: "sandbox-sub" }),
hostAuth: apiKeyAuth("host-api-key"),
},
{
name: "sandbox apikey",
sandboxAuth: apiKeyAuth("sandbox-api-key"),
hostAuth,
},
{
name: "sandbox unusable JSON",
sandboxAuth: "{not valid json",
hostAuth,
},
{
name: "sandbox account id missing",
sandboxAuth: JSON.stringify({
tokens: { id_token: "id", access_token: "acc", refresh_token: "ref" },
last_refresh: NEWER,
}),
hostAuth,
},
{
name: "host unusable JSON (never create host auth from sandbox)",
sandboxAuth: subscriptionAuth({ accountId: "acct-host", lastRefresh: NEWER, marker: "sandbox-valid" }),
hostAuth: "{not valid json",
},
];
for (const entry of cases) {
const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
expect(result.outcome, entry.name).toBe("kept-host");
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
expect(result.finalHostMode, entry.name).toBe(0o600);
expect(result.leftoverEntries, entry.name).toEqual([]);
}
});
it("preserves the host file atomically when the install cannot be staged (no partial write, no leaked temp)", async () => {
// Make the host directory read-only so staging the same-filesystem temp fails
// with EACCES. The host credential must be left byte-for-byte intact and no
// partial/temp file may remain — the outbound write is all-or-nothing.
const hostDir = await makeHostDir();
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
const hostAuthPath = path.join(hostDir, "auth.json");
await writeFile(hostAuthPath, hostAuth, { mode: 0o600 });
const before = await stat(hostAuthPath);
await chmod(hostDir, 0o500); // r-x: readable/traversable, not writable
try {
const sandboxAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "sandbox-newer" });
await expect(
copyBackCodexAuth({
readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"),
hostAuthPath,
log: () => {},
}),
).rejects.toThrow();
} finally {
await chmod(hostDir, 0o700);
}
const after = await stat(hostAuthPath);
expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth);
expect(after.mode & 0o777).toBe(0o600);
expect(after.mtimeMs).toBe(before.mtimeMs);
expect((await readdir(hostDir)).filter((name) => name !== "auth.json")).toEqual([]);
});
it("treats an absent sandbox auth.json (ENOENT) as a keep-host no-op, host untouched, no throw", async () => {
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
// The real production `readSandboxAuth` is `readFile("${assetDir}/auth.json")`;
// a genuinely absent file surfaces a node ENOENT error. That must be a benign
// "nothing to copy back" outcome, not a fail-loud teardown error.
const enoent = Object.assign(new Error("ENOENT: no such file or directory, open 'auth.json'"), {
code: "ENOENT",
});
const result = await runCopyBack({
sandboxAuth: async () => {
throw enoent;
},
hostAuth,
});
expect(result.outcome).toBe("kept-host");
expect(result.finalHostAuth).toBe(hostAuth);
expect(result.finalHostMode).toBe(0o600);
// No staging temp is ever created on the ENOENT path.
expect(result.leftoverEntries).toEqual([]);
expect(result.logs.join("\n")).toContain("no sandbox credential to copy back");
});
it("fails loud when the sandbox read errors and leaves the host untouched", async () => {
const hostDir = await makeHostDir();
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
const hostAuthPath = path.join(hostDir, "auth.json");
await writeFile(hostAuthPath, hostAuth, { mode: 0o600 });
await expect(
copyBackCodexAuth({
readSandboxAuth: async () => {
throw new Error("sandbox read boom");
},
hostAuthPath,
log: () => {},
}),
).rejects.toThrow(/sandbox read boom/);
expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth);
expect((await readdir(hostDir)).filter((name) => name !== "auth.json")).toEqual([]);
});
it("does not emit token substrings on any code path", async () => {
const sandboxAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: NEWER,
marker: "TOKEN-SENTINEL",
});
const hostAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: OLDER,
marker: "HOST-SENTINEL",
});
const result = await runCopyBack({ sandboxAuth, hostAuth });
expect(result.outcome).toBe("copied");
const combined = result.logs.join("\n");
expect(combined).not.toContain("SENTINEL");
expect(combined).not.toContain("id-token");
expect(combined).not.toContain("access-token");
expect(combined).not.toContain("refresh-token");
});
});

View File

@ -0,0 +1,153 @@
import { execFile as execFileCallback } from "node:child_process";
import { open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
const execFile = promisify(execFileCallback);
// The outbound copy-back reuses the exact same direction-agnostic decision
// predicate the inbound restore runs (`codex-auth-merge-decision.cjs`). The
// predicate answers one question — "should the caller replace `destination`
// with `source`?" — purely by argument order (first = source, second =
// destination). For the copy-back the sandbox credential is the `source` and
// the shared host credential is the `destination`, so exit 10 (use source)
// means "install the sandbox copy onto the host" and exit 20 (keep destination)
// means "leave the host copy untouched". The predicate only ever reads the two
// files and exits with a code; it never prints token bytes.
const DECISION_SCRIPT_PATH = fileURLToPath(
new URL("./codex-auth-merge-decision.cjs", import.meta.url),
);
const USE_SOURCE_EXIT = 10;
const KEEP_DESTINATION_EXIT = 20;
/** Outcome of a copy-back attempt. No token material is ever surfaced. */
export type CopyBackCodexAuthOutcome = "copied" | "kept-host";
export interface CopyBackCodexAuthInput {
/**
* Reads the sandbox `auth.json` bytes back from the (about-to-be-destroyed)
* sandbox. In production this is bound to the managed-runtime restore
* context's `readFile` for `${assetDir}/auth.json`.
*/
readSandboxAuth: () => Promise<Buffer>;
/**
* Absolute path of the shared host credential to (maybe) overwrite the
* symlink *source* the managed Codex homes point their `auth.json` at, never
* an in-sandbox or per-agent symlink.
*/
hostAuthPath: string;
/** Non-leaking progress sink: receives decision/outcome lines only. */
log: (line: string) => void | Promise<void>;
}
async function decideExitCode(sourcePath: string, destinationPath: string): Promise<number> {
try {
await execFile("node", [DECISION_SCRIPT_PATH, sourcePath, destinationPath]);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === USE_SOURCE_EXIT || code === KEEP_DESTINATION_EXIT) {
return code;
}
// A non-numeric `code` (e.g. "ENOENT" when node is not on PATH) or any exit
// code other than 10/20 is a hard failure — fail loud so a broken predicate
// is never mistaken for a "keep host" decision.
const detail =
typeof code === "string"
? `node could not be executed (${code})`
: typeof code === "number"
? `unexpected predicate exit code ${code}`
: error instanceof Error
? error.message
: String(error);
throw new Error(`codex auth copy-back decision predicate failed: ${detail}`);
}
// Reached only when `execFile` resolved — i.e. the predicate exited 0. The
// predicate always exits 10 or 20, so a clean exit 0 is unexpected; throw
// directly here, outside the try/catch, so this already self-explanatory
// message is not re-wrapped by the catch's "...failed:" prefix.
throw new Error("codex auth copy-back decision predicate exited 0 (expected 10 or 20)");
}
/**
* Guards, locks, and atomically installs a strictly-newer sandbox Codex
* `auth.json` onto the shared host credential at teardown.
*
* Sequence, all under `withDirectoryMergeLock` on the host target's directory
* so a concurrent inbound restore or another copy-back can't interleave:
* 1. Read the sandbox credential bytes. A genuinely absent sandbox
* `auth.json` (ENOENT) means there is simply nothing to copy back, so it
* resolves to `kept-host` (benign no-op, host untouched); every other read
* error stays fail-loud.
* 2. Stage them to a `0600` temp file on the **same filesystem** as the host
* target (its directory), which doubles as the predicate `source`.
* 3. Run the Phase-3 decision predicate (`source` = sandbox temp, `destination`
* = host). Exit 10 adopt the sandbox copy; exit 20 keep the host copy.
* 4. On exit 10, `rename` the staged temp over the host target an atomic
* same-directory swap that preserves mode `0600`. On exit 20, discard it.
* The staged temp is always removed (rename consumes it on the copy path; the
* finally cleans it up otherwise), so a failure never leaves a partial file.
* Never logs token bytes only the decision outcome.
*/
export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise<CopyBackCodexAuthOutcome> {
const { readSandboxAuth, hostAuthPath, log } = input;
// Read first (outside the lock) — a read never mutates the host, so there is
// nothing to serialize yet. A genuinely absent sandbox `auth.json` (ENOENT —
// e.g. Codex removed it mid-run, or a non-provisioned edge) is a "nothing to
// copy back" no-op, not a teardown failure: return `kept-host` and log the
// benign outcome. Every other read error stays fail-loud so a real read fault
// is never silently mistaken for "nothing to copy back".
let sandboxAuthBytes: Buffer;
try {
sandboxAuthBytes = await readSandboxAuth();
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") {
await log(
"[paperclip] Codex auth copy-back: no sandbox credential to copy back (absent auth.json); host credential kept.",
);
return "kept-host";
}
throw error;
}
const hostDir = path.dirname(hostAuthPath);
return withDirectoryMergeLock(hostDir, async () => {
// Stage on the same filesystem as the host target so both the predicate read
// and the final rename stay device-local (rename across devices is not
// atomic and would fail with EXDEV).
const stagedTempPath = path.join(hostDir, `.auth.json.copyback-${process.pid}-${randomUUID()}.tmp`);
// `wx` + explicit mode create the temp private (0600) and fail if it somehow
// already exists, so we never write through a pre-existing symlink.
const handle = await open(stagedTempPath, "wx", 0o600);
try {
await handle.writeFile(sandboxAuthBytes);
await handle.close();
const decision = await decideExitCode(stagedTempPath, hostAuthPath);
if (decision === USE_SOURCE_EXIT) {
// Atomic same-directory swap; rename preserves the temp's 0600 mode.
await rename(stagedTempPath, hostAuthPath);
await log(
"[paperclip] Codex auth copy-back: sandbox credential is strictly newer for the same subscription identity; installed to the host at mode 0600.",
);
return "copied";
}
await log(
"[paperclip] Codex auth copy-back: host credential kept (sandbox copy is not a strictly-newer same-identity subscription credential).",
);
return "kept-host";
} finally {
// Close is idempotent-safe to skip after an explicit close; the temp is the
// thing that must never linger. On the copy path rename already consumed it
// (force makes the removal a no-op); on every other path this deletes the
// staged credential bytes.
await handle.close().catch(() => undefined);
await rm(stagedTempPath, { force: true }).catch(() => undefined);
}
});
}

View File

@ -0,0 +1,234 @@
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SandboxManagedRuntimeAsset } from "@paperclipai/adapter-utils/sandbox-managed-runtime";
// Captured Codex `home` asset descriptor + the sandbox `auth.json` fixture the
// mocked runtime hands back during teardown. Mutated per-test so a single
// harness drives every round-trip case through the REAL `execute()` wiring.
const captured: { assets: SandboxManagedRuntimeAsset[] } = { assets: [] };
const sandboxAuthFixture: { bytes: Buffer } = { bytes: Buffer.from("{}") };
const REMOTE_RUNTIME_ROOT = "/remote/workspace/.paperclip-runtime/codex";
const {
runChildProcess,
ensureCommandResolvable,
resolveCommandForLogs,
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: 321,
startedAt: new Date().toISOString(),
})),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "/usr/bin/codex"),
prepareAdapterExecutionTargetRuntime: vi.fn(),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => null),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
"@paperclipai/adapter-utils/server-utils",
);
return {
...actual,
ensureCommandResolvable,
resolveCommandForLogs,
runChildProcess,
};
});
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js";
// Mirror the sandbox core's restore closure: capture the assets `execute()`
// declares, then during teardown invoke each asset's `restore` with an injected
// `readFile` (returns the sandbox fixture) and the remote asset dir. This drives
// the exact `restore` contribution the Codex adapter wires in production without
// needing a live sandbox.
prepareAdapterExecutionTargetRuntime.mockImplementation(async (input: { assets?: SandboxManagedRuntimeAsset[] }) => {
captured.assets = input.assets ?? [];
return {
target: { kind: "remote", transport: "ssh" },
workspaceRemoteDir: "/remote/workspace",
runtimeRootDir: REMOTE_RUNTIME_ROOT,
assetDirs: { home: `${REMOTE_RUNTIME_ROOT}/home` },
restoreWorkspace: async () => {
for (const asset of captured.assets) {
if (!asset.restore) continue;
await asset.restore({
assetDir: `${REMOTE_RUNTIME_ROOT}/home`,
readFile: async () => sandboxAuthFixture.bytes,
});
}
},
};
});
describe("codex execute — outbound auth copy-back restore contribution", () => {
const cleanupDirs: string[] = [];
let savedCodexHomeEnv: string | undefined;
afterEach(async () => {
vi.clearAllMocks();
if (savedCodexHomeEnv === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = savedCodexHomeEnv;
}
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,
);
}
async function runTeardown(input: {
sandboxAuth: string;
hostAuth: string;
}): Promise<{ finalHostAuth: string; finalHostMode: number }> {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-e2e-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
// The shared host home is what `resolveSharedCodexHomeDir` returns
// (process.env.CODEX_HOME) — the copy-back target. Point it at a tmp dir so
// the round-trip never touches the real host credential.
const sharedHostHome = path.join(rootDir, "shared-codex-home");
await mkdir(workspaceDir, { recursive: true });
await mkdir(sharedHostHome, { recursive: true });
const hostAuthPath = path.join(sharedHostHome, "auth.json");
await writeFile(hostAuthPath, input.hostAuth, { mode: 0o600 });
savedCodexHomeEnv = process.env.CODEX_HOME;
process.env.CODEX_HOME = sharedHostHome;
sandboxAuthFixture.bytes = Buffer.from(input.sandboxAuth, "utf8");
await execute({
runId: "run-copyback-e2e",
agent: {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: "codex",
engine: "cli",
// External CODEX_HOME (outside the managed company tree) so no managed
// seeding rewrites auth.json before teardown; equals the shared host home.
env: { CODEX_HOME: sharedHostHome },
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
return {
finalHostAuth: await readFile(hostAuthPath, "utf8"),
finalHostMode: (await lstat(hostAuthPath)).mode & 0o777,
};
}
it("declares a Codex `home` asset carrying both inbound provision and outbound restore contributions", async () => {
await runTeardown({
sandboxAuth: subscriptionAuth({ accountId: "acct", lastRefresh: "2026-07-09T01:00:00Z", marker: "s" }),
hostAuth: subscriptionAuth({ accountId: "acct", lastRefresh: "2026-07-09T02:00:00Z", marker: "h" }),
});
const homeAsset = captured.assets.find((asset) => asset.key === "home");
expect(homeAsset).toBeDefined();
expect(homeAsset?.provision).toBeTruthy();
expect(typeof homeAsset?.restore).toBe("function");
});
it("round-trips a strictly-newer same-identity sandbox auth.json to the shared host at 0600 on teardown", async () => {
const sandboxAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T02:00:00Z",
marker: "sandbox-newer",
});
const hostAuth = subscriptionAuth({
accountId: "acct-same",
lastRefresh: "2026-07-09T01:00:00Z",
marker: "host-older",
});
const result = await runTeardown({ sandboxAuth, hostAuth });
expect(result.finalHostAuth).toBe(sandboxAuth);
expect(result.finalHostMode).toBe(0o600);
});
it("keeps the host auth.json when the sandbox copy is a tie or older on teardown", async () => {
const cases = [
{
name: "tie",
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "2026-07-09T02:00:00Z", marker: "s-tie" }),
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "2026-07-09T02:00:00Z", marker: "h-tie" }),
},
{
name: "older",
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "2026-07-09T01:00:00Z", marker: "s-old" }),
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "2026-07-09T02:00:00Z", marker: "h-new" }),
},
];
for (const entry of cases) {
const result = await runTeardown({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
expect(result.finalHostMode, entry.name).toBe(0o600);
}
});
});

View File

@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js";
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetRemoteCwd,
@ -636,6 +637,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// credential is newer. The sandbox runtime core stays adapter-
// agnostic — it just invokes this generic `provision` seam.
provision: buildCodexAuthInboundProvision(),
// Outbound (sandbox→host) auth copy-back contribution: at
// teardown, read the sandbox's `auth.json` and — guarded by the
// same direction-agnostic decision predicate under a directory
// lock — atomically install it onto the shared host credential
// when it is a strictly-newer same-identity subscription copy.
// The sandbox core stays adapter-agnostic; it just awaits this
// generic `restore` seam per asset before destroying the sandbox.
// Target is the shared symlink SOURCE (what managed homes point
// `auth.json` at), not the in-sandbox symlink.
restore: async ({ assetDir, readFile }) =>
void (await copyBackCodexAuth({
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
log: (line) => onLog("stdout", `${line}\n`),
})),
// Exclude state that the sandbox run never needs so we don't
// tar/upload hundreds of MB on every run:
// - `tmp`/`.tmp`: transient dirs that can hold symlinks to the