refactor(codex-local): relocate Codex auth-merge scripts + decision predicate into the adapter (#9785)

## 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 <noreply@paperclip.ing>` 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 <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-17 14:45:36 -05:00 committed by GitHub
parent cf5ba4bbea
commit 7ffeafad1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 360 additions and 355 deletions

View File

@ -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"
},

View File

@ -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");
}
});
});

View File

@ -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"

View File

@ -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.

View File

@ -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");
}
});
});

View File

@ -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,