diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index adf2a237c1..4c846f7ccc 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -35,7 +35,7 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc && cp src/codex-auth-merge-decision.cjs src/codex-auth-merge-extract.sh dist/", "clean": "rm -rf dist", "typecheck": "tsc --noEmit" }, diff --git a/packages/adapter-utils/src/codex-auth-merge-decision.cjs b/packages/adapter-utils/src/codex-auth-merge-decision.cjs new file mode 100644 index 0000000000..fa08fee82a --- /dev/null +++ b/packages/adapter-utils/src/codex-auth-merge-decision.cjs @@ -0,0 +1,72 @@ +const fs = require("fs"); + +// Co-change notice: parseAuth below mirrors hasUsableAuthPayload in +// packages/adapters/codex-local/src/server/codex-home.ts. If the auth format +// changes (new shape, renamed field), update both sites together. +function parseAuth(filePath) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return { kind: "unusable" }; + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { kind: "unusable" }; + } + + if (typeof parsed.OPENAI_API_KEY === "string" && parsed.OPENAI_API_KEY.trim().length > 0) { + return { kind: "apikey" }; + } + + const tokens = parsed.tokens; + if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) { + return { kind: "unusable" }; + } + + const accountId = typeof tokens.account_id === "string" ? tokens.account_id.trim() : ""; + const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => { + const value = tokens[key]; + return typeof value === "string" && value.trim().length > 0; + }); + if (!accountId || !hasTokenMaterial) { + return { kind: "unusable" }; + } + + const lastRefresh = typeof parsed.last_refresh === "string" ? Date.parse(parsed.last_refresh) : NaN; + return { + kind: "subscription", + accountId, + lastRefresh: Number.isFinite(lastRefresh) ? lastRefresh : null, + }; +} + +const [sandboxAuthPath, hostAuthPath] = process.argv.slice(2); +const sandboxAuth = parseAuth(sandboxAuthPath); +const hostAuth = parseAuth(hostAuthPath); + +if ( + hostAuth.kind === "unusable" || + sandboxAuth.kind === "unusable" || + sandboxAuth.kind !== hostAuth.kind +) { + process.exit(20); +} + +if (hostAuth.kind === "apikey") { + process.exit(20); +} + +if (sandboxAuth.accountId !== hostAuth.accountId) { + process.exit(20); +} + +if ( + hostAuth.lastRefresh !== null && + sandboxAuth.lastRefresh !== null && + hostAuth.lastRefresh > sandboxAuth.lastRefresh +) { + process.exit(20); +} + +process.exit(10); diff --git a/packages/adapter-utils/src/codex-auth-merge-extract.sh b/packages/adapter-utils/src/codex-auth-merge-extract.sh new file mode 100755 index 0000000000..37dbe60c63 --- /dev/null +++ b/packages/adapter-utils/src/codex-auth-merge-extract.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +if [ "$#" -ne 2 ]; then + echo "Usage: codex-auth-merge-extract.sh " >&2 + exit 1 +fi + +asset_dir=$1 +asset_tar=$2 +auth_name=auth.json +stage_root="$asset_dir.paperclip-extract.$$" +stage_dir="$stage_root/stage" +preserve_dir="$stage_root/preserve" +sandbox_auth="$asset_dir/$auth_name" +host_auth="$stage_dir/$auth_name" +preserve_auth="$preserve_dir/$auth_name" +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) || exit 1 +decision_script="$script_dir/codex-auth-merge-decision.cjs" + +cleanup() { + rm -rf "$stage_root" "$asset_tar" +} +trap cleanup EXIT HUP INT TERM +rm -rf "$stage_root" && +mkdir -p "$stage_dir" "$preserve_dir" && +tar -xf "$asset_tar" -C "$stage_dir" || exit 1 + +keep_sandbox=0 +if [ -f "$sandbox_auth" ]; then + if command -v node >/dev/null 2>&1; then + node "$decision_script" "$sandbox_auth" "$host_auth" + decision_rc=$? + if [ "$decision_rc" -eq 10 ]; then + keep_sandbox=1 + else + keep_sandbox=0 + fi + else + echo "[paperclip] node not found in PATH; cannot evaluate auth-merge decision - aborting sandbox restore" >&2 + exit 1 + fi +fi + +if [ "$keep_sandbox" -eq 1 ]; then + if ! ( umask 077 && rm -f "$preserve_auth" && cat "$sandbox_auth" > "$preserve_auth" ); then + keep_sandbox=0 + fi +fi + +rm -rf "$asset_dir" || exit 1 +mkdir -p "$asset_dir" || exit 1 +find "$stage_dir" -mindepth 1 -maxdepth 1 ! -name "$auth_name" -exec mv -f -- {} "$asset_dir/" \; || exit 1 + +source_auth="$host_auth" +if [ "$keep_sandbox" -eq 1 ] && [ -f "$preserve_auth" ]; then + source_auth="$preserve_auth" +fi + +if [ -f "$source_auth" ]; then + target_auth="$asset_dir/$auth_name" + target_tmp="$asset_dir/.auth.json.paperclip.$$" + ( umask 077 && rm -f "$target_tmp" && cat "$source_auth" > "$target_tmp" ) || exit 1 + mv -f "$target_tmp" "$target_auth" || exit 1 +fi diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 9edb59703c..10dc8ab40a 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1,5 +1,5 @@ import { execFile as execFileCallback } from "node:child_process"; -import { constants as fsConstants, promises as fs } from "node:fs"; +import { constants as fsConstants, promises as fs, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -27,6 +27,14 @@ import { import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js"; const execFile = promisify(execFileCallback); +const CODEX_AUTH_MERGE_EXTRACT_SCRIPT_NAME = "codex-auth-merge-extract.sh"; +const CODEX_AUTH_MERGE_DECISION_SCRIPT_NAME = "codex-auth-merge-decision.cjs"; +const CODEX_AUTH_MERGE_EXTRACT_SCRIPT_BYTES = readFileSync( + new URL(`./${CODEX_AUTH_MERGE_EXTRACT_SCRIPT_NAME}`, import.meta.url), +); +const CODEX_AUTH_MERGE_DECISION_SCRIPT_BYTES = readFileSync( + new URL(`./${CODEX_AUTH_MERGE_DECISION_SCRIPT_NAME}`, import.meta.url), +); const SANDBOX_WORKSPACE_HEAVY_DIR_NAMES = [ "node_modules", "vendor", @@ -110,6 +118,28 @@ function shellQuote(value: string) { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } +function buildExtractRuntimeAssetCommand(input: { + adapterKey: string; + assetKey: string; + remoteAssetDir: string; + remoteAssetTar: string; + remoteCodexAuthMergeExtractScript?: string; +}): string { + if (input.adapterKey !== "codex" || input.assetKey !== "home") { + return `rm -rf ${shellQuote(input.remoteAssetDir)} && ` + + `mkdir -p ${shellQuote(input.remoteAssetDir)} && ` + + `tar -xf ${shellQuote(input.remoteAssetTar)} -C ${shellQuote(input.remoteAssetDir)} && ` + + `rm -f ${shellQuote(input.remoteAssetTar)}`; + } + + if (!input.remoteCodexAuthMergeExtractScript) { + throw new Error("Codex auth merge extract script path is required for codex home assets"); + } + + return `sh ${shellQuote(input.remoteCodexAuthMergeExtractScript)} ` + + `${shellQuote(input.remoteAssetDir)} ${shellQuote(input.remoteAssetTar)}`; +} + export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteExecutionSpec | null { const parsed = asObject(value); const transport = asString(parsed.transport).trim(); @@ -564,6 +594,9 @@ export async function prepareSandboxManagedRuntime(input: { const assetTarBytes = await fs.readFile(assetTarPath); const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key); const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`); + const remoteCodexAuthMergeExtractScript = input.adapterKey === "codex" && asset.key === "home" + ? path.posix.join(runtimeRootDir, CODEX_AUTH_MERGE_EXTRACT_SCRIPT_NAME) + : undefined; const assetUpload = makeTransferProgress( input.onProgress, "Syncing", @@ -573,12 +606,25 @@ export async function prepareSandboxManagedRuntime(input: { ); await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes), assetUpload.options); await assetUpload.finish(assetTarBytes.byteLength, assetTarBytes.byteLength); + if (remoteCodexAuthMergeExtractScript) { + await input.client.writeFile( + remoteCodexAuthMergeExtractScript, + toArrayBuffer(CODEX_AUTH_MERGE_EXTRACT_SCRIPT_BYTES), + ); + await input.client.writeFile( + path.posix.join(runtimeRootDir, CODEX_AUTH_MERGE_DECISION_SCRIPT_NAME), + toArrayBuffer(CODEX_AUTH_MERGE_DECISION_SCRIPT_BYTES), + ); + } await input.client.run( `sh -c ${shellQuote( - `rm -rf ${shellQuote(remoteAssetDir)} && ` + - `mkdir -p ${shellQuote(remoteAssetDir)} && ` + - `tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` + - `rm -f ${shellQuote(remoteAssetTar)}`, + buildExtractRuntimeAssetCommand({ + adapterKey: input.adapterKey, + assetKey: asset.key, + remoteAssetDir, + remoteAssetTar, + remoteCodexAuthMergeExtractScript, + }), )}`, { timeoutMs: input.spec.timeoutMs }, ); diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index ff0a5a126d..e7e907c278 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -1,11 +1,19 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { lstat, 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 { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; +const execFile = promisify(execFileCallback); + describe("workspace restore merge", () => { const cleanupDirs: string[] = []; @@ -82,3 +90,334 @@ 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, + }], + }); + + 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/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts index 06f60b540e..92e0a1ec0d 100644 --- a/packages/adapters/codex-local/src/server/codex-home.test.ts +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -52,7 +52,7 @@ describe("codex managed home", () => { const managedAuth = path.join(managedCodexHome, "auth.json"); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); + await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}\n', "utf8"); const originalSymlink = fs.symlink.bind(fs); vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => { @@ -99,7 +99,7 @@ describe("codex managed home", () => { const managedAuth = path.join(managedCodexHome, "auth.json"); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); + await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}\n', "utf8"); await fs.writeFile(wrongAuth, '{"token":"other"}\n', "utf8"); const originalSymlink = fs.symlink.bind(fs); @@ -275,12 +275,72 @@ describe("codexHomeHasUsableAuth", () => { await fs.writeFile(path.join(root, "auth.json"), '{"foo":"bar"}', "utf8"); expect(await codexHomeHasUsableAuth(root)).toBe(false); await fs.writeFile(path.join(root, "auth.json"), '{"token":"shared"}', "utf8"); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + await fs.writeFile(path.join(root, "auth.json"), '{"access_token":"shared"}', "utf8"); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + await fs.writeFile(path.join(root, "auth.json"), '{"OPENAI_API_KEY":"shared"}', "utf8"); expect(await codexHomeHasUsableAuth(root)).toBe(true); } finally { await fs.rm(root, { recursive: true, force: true }); } }); + it("recognizes the Codex 0.143 AuthDotJson subscription shape", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-modern-")); + try { + await fs.writeFile( + path.join(root, "auth.json"), + JSON.stringify({ + tokens: { + id_token: "synthetic-id-token", + access_token: "synthetic-access-token", + refresh_token: "synthetic-refresh-token", + account_id: "acct-modern", + }, + last_refresh: "2026-07-09T00:00:00Z", + }), + "utf8", + ); + + expect(await codexHomeHasUsableAuth(root)).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("treats subscription auth without account_id or token material as unusable", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-modern-invalid-")); + try { + await fs.writeFile( + path.join(root, "auth.json"), + JSON.stringify({ + tokens: { + id_token: "synthetic-id-token", + access_token: "synthetic-access-token", + refresh_token: "synthetic-refresh-token", + }, + last_refresh: "2026-07-09T00:00:00Z", + }), + "utf8", + ); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + + await fs.writeFile( + path.join(root, "auth.json"), + JSON.stringify({ + tokens: { + account_id: "acct-modern", + }, + last_refresh: "2026-07-09T00:00:00Z", + }), + "utf8", + ); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("is false for a dangling auth.json symlink", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-dangling-")); try { @@ -311,7 +371,7 @@ describe("seedManagedCodexHome", () => { const agentAuth = path.join(agentHome, "auth.json"); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(sharedAuth, '{"token":"shared"}', "utf8"); + await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}', "utf8"); await seedManagedCodexHome(agentHome, { CODEX_HOME: sharedCodexHome }, async () => {}); @@ -359,7 +419,7 @@ describe("reconcileManagedCodexHome", () => { const sharedAuth = path.join(sharedCodexHome, "auth.json"); const agentAuth = path.join(agentHome, "auth.json"); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(sharedAuth, '{"token":"shared"}', "utf8"); + await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}', "utf8"); const env = { CODEX_HOME: sharedCodexHome, PAPERCLIP_HOME: paperclipHome, diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 4912d2ffff..0739066a76 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -7,7 +7,6 @@ import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-uti const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; const SYMLINKED_SHARED_FILES = ["auth.json"] as const; -const AUTH_CREDENTIAL_KEYS = /(?:openai[_-]?key|api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|session|auth)/i; const MANAGED_MCP_BLOCK_START = "# BEGIN PAPERCLIP MANAGED MCP"; const MANAGED_MCP_BLOCK_END = "# END PAPERCLIP MANAGED MCP"; @@ -39,15 +38,30 @@ export async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } +// Co-change notice: this function's logic is mirrored by parseAuth in +// packages/adapter-utils/src/sandbox-managed-runtime.ts (buildCodexAuthMergeDecisionScript). +// If the auth format changes (new shape, renamed field), update both sites together. function hasUsableAuthPayload(authPayload: unknown): boolean { if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) { return false; } - for (const [key, value] of Object.entries(authPayload as Record)) { - if (!AUTH_CREDENTIAL_KEYS.test(key)) continue; - if (key.toLowerCase() === "token_type") continue; - if (typeof value === "string" && value.trim().length > 0) return true; + const parsedPayload = authPayload as Record; + const apiKey = parsedPayload.OPENAI_API_KEY; + if (typeof apiKey === "string" && apiKey.trim().length > 0) { + return true; + } + + const tokens = parsedPayload.tokens; + if (tokens !== null && typeof tokens === "object" && !Array.isArray(tokens)) { + const parsedTokens = tokens as Record; + const accountId = parsedTokens.account_id; + const hasAccountId = typeof accountId === "string" && accountId.trim().length > 0; + const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => { + const value = parsedTokens[key]; + return typeof value === "string" && value.trim().length > 0; + }); + if (hasAccountId && hasTokenMaterial) return true; } return false; diff --git a/server/src/__tests__/codex-auth-reconciliation.test.ts b/server/src/__tests__/codex-auth-reconciliation.test.ts index 5ab8ab7626..af98eedf8c 100644 --- a/server/src/__tests__/codex-auth-reconciliation.test.ts +++ b/server/src/__tests__/codex-auth-reconciliation.test.ts @@ -45,7 +45,7 @@ describe("reconcileCodexLocalManagedHomesOnStartup", () => { paperclipHome = path.join(root, "paperclip-home"); sharedCodexHome = path.join(root, "shared-codex-home"); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"OPENAI_API_KEY":"sk-shared"}', "utf8"); for (const key of ["PAPERCLIP_HOME", "PAPERCLIP_INSTANCE_ID", "CODEX_HOME"]) { savedEnv[key] = process.env[key]; diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index c28dc5d603..415cd6647b 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -62,6 +62,8 @@ type LogEntry = { chunk: string; }; +const fakeCodexAuthJson = JSON.stringify({ OPENAI_API_KEY: "sk-test-codex-local" }); + const codexHomeOverrides: Array = []; afterEach(() => { @@ -77,7 +79,7 @@ async function seedSharedCodexAuth(homeRoot: string): Promise { codexHomeOverrides.push(process.env.CODEX_HOME); process.env.CODEX_HOME = sharedCodexHome; await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), `${fakeCodexAuthJson}\n`, "utf8"); } function createLocalSandboxRunner() { @@ -132,7 +134,7 @@ describe("codex execute", () => { ); await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), `${fakeCodexAuthJson}\n`, "utf8"); await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8"); await writeFakeCodexCommand(commandPath); @@ -234,7 +236,7 @@ describe("codex execute", () => { ); await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), `${fakeCodexAuthJson}\n`, "utf8"); await fs.writeFile( path.join(sharedCodexHome, "config.toml"), [ @@ -1226,7 +1228,7 @@ describe("codex execute", () => { const homeSkill = path.join(isolatedCodexHome, "skills", "paperclip"); await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), `${fakeCodexAuthJson}\n`, "utf8"); await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8"); await writeFakeCodexCommand(commandPath); @@ -1339,7 +1341,7 @@ describe("codex execute", () => { const paperclipHome = path.join(root, "paperclip-home"); await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(sharedCodexHome, { recursive: true }); - await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), `${fakeCodexAuthJson}\n`, "utf8"); await writeFakeCodexCommand(commandPath); const previousHome = process.env.HOME;