From 7ffeafad1f562559db39ea08d98263e952fdc858 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 17 Jul 2026 14:45:36 -0500 Subject: [PATCH] refactor(codex-local): relocate Codex auth-merge scripts + decision predicate into the adapter (#9785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is an open source platform for managing AI agent teams, with adapter packages providing concrete runtime environments (e.g. `codex-local` runs a local OpenAI Codex sandbox) > - `adapter-utils` is the shared utilities package — it should hold only generic, adapter-agnostic primitives (sandbox lifecycle helpers, merge logic, type definitions) usable by every adapter > - Three files lived in `adapter-utils/src/` that are entirely Codex-specific: `codex-auth-merge-extract.sh` (shell script that extracts auth tokens), `codex-auth-merge-decision.cjs` (CJS decision helper), and `codex-auth-merge-scripts.ts` (TypeScript factory that wires them into the inbound provision seam added in PR #9778) > - Their presence in a "generic" package violates the adapter isolation principle and requires a cross-package build step to copy `.sh`/`.cjs` files into `codex-local/dist/server/` at build time > - This PR completes Phase 2 of the inbound-seam refactor: move all three files to `packages/adapters/codex-local/src/server/`, rebase the TypeScript imports, update `execute.ts` to import locally, move the Codex-auth tests into a new `codex-local` test file, and fix packaging so `codex-local` copies its own scripts to `dist/server/` > - Script bytes are identical after the move; no change to inbound auth-merge behavior or to which bytes cross the sandbox boundary > - The benefit is a clean ownership boundary: `adapter-utils` retains only generic runtime code, and the structural "Codex-free core" test in the adapter-utils suite validates this invariant going forward ## Linked Issues or Issue Description No pre-existing public GitHub issue. Describing the underlying problem inline: **Problem:** `packages/adapter-utils/src/` contains three files (`codex-auth-merge-extract.sh`, `codex-auth-merge-decision.cjs`, `codex-auth-merge-scripts.ts`) consumed exclusively by the `codex-local` adapter. Their presence in a generic utilities package violates adapter isolation and requires a cross-package build step (copy `.sh`/`.cjs` into `codex-local/dist/server/`). The inbound provision seam landed in PR #9778 routed these through `adapter-utils`; this PR finishes the relocation. **Related:** Refs #9778 (Phase 1 — generic asset-lifecycle-seam, now merged). ## What Changed - Moved `codex-auth-merge-extract.sh`, `codex-auth-merge-decision.cjs`, and `codex-auth-merge-scripts.ts` from `packages/adapter-utils/src/` → `packages/adapters/codex-local/src/server/` (script bytes are unchanged; `codex-auth-merge-scripts.ts` imports rebased to `adapter-utils` subpaths for `shellQuote` and `SandboxManagedRuntimeAssetProvision`) - `packages/adapters/codex-local/src/server/execute.ts`: updated import of `buildCodexAuthInboundProvision` from `adapter-utils` → local `./codex-auth-merge-scripts` - New `packages/adapters/codex-local/src/server/codex-auth-merge.test.ts` — 5 Codex-auth test rows extracted from `adapter-utils/src/workspace-restore-merge.test.ts`; the generic test file stays and no longer references Codex - `packages/adapters/codex-local/package.json`: build now copies `.sh`/`.cjs` from `src/server/` into `dist/server/` directly - `packages/adapter-utils/package.json`: removed the now-obsolete cross-package copy step for those files ## Verification ```sh # Type-check both affected packages pnpm --filter @paperclipai/adapter-utils --filter @paperclipai/adapter-codex-local typecheck # Codex auth-merge suite (moved tests — 5/5 pass) pnpm --filter @paperclipai/adapter-codex-local test -- --reporter=verbose codex-auth-merge # Full adapter-utils suite including the structural "core free of Codex literals" test (243 passed, 4 pre-existing skips) pnpm --filter @paperclipai/adapter-utils test # Confirm build copies scripts to dist/server pnpm --filter @paperclipai/adapter-codex-local build ls packages/adapters/codex-local/dist/server/*.sh packages/adapters/codex-local/dist/server/*.cjs ``` All of the above were run locally and passed before this PR was opened. ## Risks Low risk — pure file relocation: - No change to `.sh` or `.cjs` script bytes; the same content reaches the sandbox boundary as before - No behavioral change to inbound auth-merge logic from the sandbox's perspective - `adapter-utils`' structural "core free of Codex literals" test now validates that the relocation is complete and will catch any regression - Only `codex-local` consumed these files from `adapter-utils`; no other package in the monorepo imported them from there ## Model Used Claude Sonnet 4.6 (`claude-sonnet-4-6`) — Anthropic, 200k context window, tool use, agentic reasoning. Used to author the refactor. `Co-authored-by: Paperclip ` trailer present on all commits. ## 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 Co-authored-by: Harold Kim Co-authored-by: Paperclip --- packages/adapter-utils/package.json | 2 +- .../src/workspace-restore-merge.test.ts | 347 +---------------- packages/adapters/codex-local/package.json | 2 +- .../src/server}/codex-auth-merge-decision.cjs | 0 .../src/server}/codex-auth-merge-extract.sh | 0 .../src/server}/codex-auth-merge-scripts.ts | 12 +- .../src/server/codex-auth-merge.test.ts | 350 ++++++++++++++++++ .../codex-local/src/server/execute.ts | 2 +- 8 files changed, 360 insertions(+), 355 deletions(-) rename packages/{adapter-utils/src => adapters/codex-local/src/server}/codex-auth-merge-decision.cjs (100%) rename packages/{adapter-utils/src => adapters/codex-local/src/server}/codex-auth-merge-extract.sh (100%) rename packages/{adapter-utils/src => adapters/codex-local/src/server}/codex-auth-merge-scripts.ts (78%) create mode 100644 packages/adapters/codex-local/src/server/codex-auth-merge.test.ts diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 4c846f7ccc..adf2a237c1 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -35,7 +35,7 @@ "dist" ], "scripts": { - "build": "tsc && cp src/codex-auth-merge-decision.cjs src/codex-auth-merge-extract.sh dist/", + "build": "tsc", "clean": "rm -rf dist", "typecheck": "tsc --noEmit" }, diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index a34e9326ba..ff0a5a126d 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -1,20 +1,11 @@ -import { execFile as execFileCallback } from "node:child_process"; -import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { - prepareSandboxManagedRuntime, - type SandboxManagedRuntimeClient, -} from "./sandbox-managed-runtime.js"; -import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js"; import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; -const execFile = promisify(execFileCallback); - describe("workspace restore merge", () => { const cleanupDirs: string[] = []; @@ -91,339 +82,3 @@ describe("workspace restore merge", () => { } }); }); - -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; - }): 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"); - await writeFile(path.join(localHomeDir, "auth.json"), input.hostAuth, { mode: 0o600 }); - await writeFile(path.join(localHomeDir, "config.toml"), "model = \"gpt\"\n", "utf8"); - await writeFile(path.join(remoteHomeDir, "auth.json"), input.sandboxAuth, { 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 }); - outputs.push(result.stdout, result.stderr); - }, - }; - - 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 now rides the generic per-asset - // `provision` seam. This matrix drives the sandbox core directly, so it - // supplies 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 same-account sandbox auth when freshness is equal, missing, or unparseable", async () => { - 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.sandboxAuth); - 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"); - } - }); -}); diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index cb098ab083..f286310839 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -46,7 +46,7 @@ "skills" ], "scripts": { - "build": "tsc", + "build": "tsc && cp src/server/codex-auth-merge-decision.cjs src/server/codex-auth-merge-extract.sh dist/server/", "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json" diff --git a/packages/adapter-utils/src/codex-auth-merge-decision.cjs b/packages/adapters/codex-local/src/server/codex-auth-merge-decision.cjs similarity index 100% rename from packages/adapter-utils/src/codex-auth-merge-decision.cjs rename to packages/adapters/codex-local/src/server/codex-auth-merge-decision.cjs diff --git a/packages/adapter-utils/src/codex-auth-merge-extract.sh b/packages/adapters/codex-local/src/server/codex-auth-merge-extract.sh similarity index 100% rename from packages/adapter-utils/src/codex-auth-merge-extract.sh rename to packages/adapters/codex-local/src/server/codex-auth-merge-extract.sh diff --git a/packages/adapter-utils/src/codex-auth-merge-scripts.ts b/packages/adapters/codex-local/src/server/codex-auth-merge-scripts.ts similarity index 78% rename from packages/adapter-utils/src/codex-auth-merge-scripts.ts rename to packages/adapters/codex-local/src/server/codex-auth-merge-scripts.ts index ef6b79f5aa..a9c6cb87f2 100644 --- a/packages/adapter-utils/src/codex-auth-merge-scripts.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-merge-scripts.ts @@ -1,12 +1,12 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { shellQuote } from "./ssh.js"; -import type { SandboxManagedRuntimeAssetProvision } from "./sandbox-managed-runtime.js"; +import { shellQuote } from "@paperclipai/adapter-utils/ssh"; +import type { SandboxManagedRuntimeAssetProvision } from "@paperclipai/adapter-utils/sandbox-managed-runtime"; -// Codex-specific inbound auth-merge assets. These physically live in -// `adapter-utils/src` in Phase 1 of the generic-asset-lifecycle-seam work; -// a follow-on phase will relocate this module and the two script files -// into the `codex-local` adapter. The sandbox runtime *core* +// Codex-specific inbound auth-merge assets. These live alongside the Codex +// adapter server code; the two script files (`codex-auth-merge-extract.sh` and +// `codex-auth-merge-decision.cjs`) are read from this directory at runtime and +// staged into the sandbox. The sandbox runtime *core* // (`sandbox-managed-runtime.ts`) is intentionally free of any Codex knowledge — // the adapter supplies this contribution through the generic `provision` seam. diff --git a/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts b/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts new file mode 100644 index 0000000000..c874bd5419 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-auth-merge.test.ts @@ -0,0 +1,350 @@ +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 { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + prepareSandboxManagedRuntime, + type SandboxManagedRuntimeClient, +} 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; + }): 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"); + await writeFile(path.join(localHomeDir, "auth.json"), input.hostAuth, { mode: 0o600 }); + await writeFile(path.join(localHomeDir, "config.toml"), "model = \"gpt\"\n", "utf8"); + await writeFile(path.join(remoteHomeDir, "auth.json"), input.sandboxAuth, { 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 }); + outputs.push(result.stdout, result.stderr); + }, + }; + + 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 same-account sandbox auth when freshness is equal, missing, or unparseable", async () => { + 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.sandboxAuth); + 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"); + } + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index cb8c882e53..16a6f904df 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { buildCodexAuthInboundProvision } from "@paperclipai/adapter-utils/codex-auth-merge-scripts"; +import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js"; import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd,