diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts new file mode 100644 index 0000000000..18b706b075 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts @@ -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 { + 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); + hostAuth: string; + hostDir?: string; + }): Promise<{ + outcome: Awaited>; + 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"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts new file mode 100644 index 0000000000..f2649ef28d --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts @@ -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; + /** + * 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; +} + +async function decideExitCode(sourcePath: string, destinationPath: string): Promise { + 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 { + 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); + } + }); +} diff --git a/packages/adapters/codex-local/src/server/execute.test.ts b/packages/adapters/codex-local/src/server/execute.test.ts new file mode 100644 index 0000000000..a49073d8b0 --- /dev/null +++ b/packages/adapters/codex-local/src/server/execute.test.ts @@ -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( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@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); + } + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 16a6f904df..a81deaad26 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -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 + 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