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:
parent
cf5ba4bbea
commit
7ffeafad1f
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue