feat(codex-local): outbound auth copy-back as home-asset restore contribution (#9788)
This commit is contained in:
parent
936215693c
commit
14da75dfc7
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue