paperclip/packages/adapters/codex-local/src/server/acp.test.ts

1357 lines
48 KiB
TypeScript

import fs from "node:fs/promises";
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
// Every test in this file needs a real teardown, so the mock below delegates
// to the actual factory by default. Only the wiring test further down reads
// the call arguments; it does not change this behavior.
const mockCreateWorkspaceRestoreTeardown = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/workspace-restore-teardown", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
mockCreateWorkspaceRestoreTeardown.mockImplementation(
actual.createWorkspaceRestoreTeardown as (...args: unknown[]) => unknown,
);
return {
...actual,
createWorkspaceRestoreTeardown: mockCreateWorkspaceRestoreTeardown,
};
});
import {
buildCodexAcpConfig,
createCodexAcpExecutor,
nodeVersionMeetsCodexAcpMinimum,
resolveCodexAcpBillingIdentity,
resolveCodexExecutionEngine,
resolveCodexExecutionEngineForRun,
testCodexAcpEnvironment,
} from "./acp.js";
// A local stand-in for a sandbox runner: runs the managed-runtime staging
// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can
// be exercised end-to-end against the host filesystem.
function createLocalSandboxRunner() {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) => {
counter += 1;
const command = input.command === "bash" ? "/bin/bash" : input.command;
return await runChildProcess(`codex-acp-sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
});
},
};
}
type FakeRuntimeOptions = Record<string, unknown>;
type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string };
type FakeRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
cwd?: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
};
type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string };
type FakeRuntimeTurn = {
requestId: string;
events: AsyncIterable<FakeRuntimeEvent>;
result: Promise<FakeRuntimeTurnResult>;
cancel: () => Promise<void>;
closeStream: () => Promise<void>;
};
const tempRoots: string[] = [];
const originalNodeVersion = process.version;
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const originalCodexHome = process.env.CODEX_HOME;
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
// Older/newer ISO timestamps for the copy-back monotonic (strictly-newer)
// decision predicate, plus a subscription-shaped auth.json fixture matching the
// predicate's parseAuth contract (tokens.account_id + token material +
// last_refresh).
const OLDER_REFRESH = "2026-01-01T00:00:00.000Z";
const NEWER_REFRESH = "2026-06-01T00:00:00.000Z";
function subscriptionAuthJson(accountId: string, lastRefresh: string, marker: string): string {
return JSON.stringify(
{
tokens: {
id_token: `id-${marker}`,
access_token: `acc-${marker}`,
refresh_token: `ref-${marker}`,
account_id: accountId,
},
last_refresh: lastRefresh,
},
null,
2,
);
}
// Enumerate the host staged-home temp dirs `stageCodexHomeForSync` created for a
// given runId (`paperclip-codex-home-sync-<runId>-<random>` under os.tmpdir()).
// A unique per-test runId scopes the match to this run's staging dirs only, so
// the assertion is not disturbed by other tests/processes sharing the tmp dir.
async function listCodexHomeSyncDirs(runId: string): Promise<string[]> {
const prefix = `paperclip-codex-home-sync-${runId}-`;
const entries = await fs.readdir(os.tmpdir());
return entries.filter((name) => name.startsWith(prefix)).map((name) => path.join(os.tmpdir(), name));
}
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
enumerable: true,
value: version,
});
}
afterEach(async () => {
setNodeVersion(originalNodeVersion);
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = originalCodexHome;
if (originalOpenAiApiKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalOpenAiApiKey;
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
class FakeRuntime {
ensureInputs: Array<{
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}> = [];
startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = [];
closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = [];
setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = [];
ensureCount = 0;
constructor(
readonly options: FakeRuntimeOptions,
readonly events: FakeRuntimeEvent[] = [
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
],
readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" },
) {}
async ensureSession(input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
resumeSessionId?: string;
}): Promise<FakeRuntimeHandle> {
this.ensureInputs.push(input);
this.ensureCount += 1;
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `runtime-${this.ensureCount}`,
cwd: input.cwd,
acpxRecordId: `record-${this.ensureCount}`,
backendSessionId: `acp-${this.ensureCount}`,
agentSessionId: `agent-${this.ensureCount}`,
};
}
startTurn(input: {
handle: FakeRuntimeHandle;
text: string;
requestId: string;
timeoutMs?: number;
}): FakeRuntimeTurn {
this.startInputs.push(input);
const events = this.events;
const terminal = this.terminal;
return {
requestId: input.requestId,
events: {
[Symbol.asyncIterator]: async function* () {
for (const event of events) yield event;
},
},
result: Promise.resolve(terminal),
cancel: async () => {},
closeStream: async () => {},
};
}
runTurn(): AsyncIterable<FakeRuntimeEvent> {
throw new Error("not used");
}
getCapabilities() {
return { controls: [] };
}
getStatus() {
return Promise.resolve({});
}
async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) {
this.setConfigInputs.push(input);
}
async setMode() {}
async cancel() {}
async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) {
this.closeInputs.push(input);
}
}
async function makeTempRoot(prefix: string) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempRoots.push(root);
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
return root;
}
async function createRuntimeSkill(root: string) {
const source = path.join(root, "skills", "review");
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
return {
key: "company/review",
runtimeName: "review",
source,
};
}
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
return {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex ACP",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: "PAP-1",
},
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
env: {
CODEX_HOME: path.join(root, "codex-home"),
},
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipTaskMarkdown: "Task context",
paperclipWorkspace: {
cwd: root,
source: "project_workspace",
workspaceId: "workspace-1",
},
},
onLog: async () => {},
...overrides,
};
}
describe("codex_local ACP lane", () => {
it("keeps ACP selected and reports unavailable prerequisites for default and explicit engines", async () => {
const root = await makeTempRoot("paperclip-codex-acp-default-");
const commandPath = path.join(root, "bin", "codex-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v24.11.0");
expect(resolveCodexExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "acp", explicit: false });
await expect(
resolveCodexExecutionEngineForRun({
config: { engine: "cli", agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toEqual({ engine: "cli", explicit: true });
expect(resolveCodexExecutionEngine({ engine: "acp" })).toEqual({
engine: "acp",
explicit: true,
});
setNodeVersion("v24.10.0");
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: commandPath },
executionTarget: null,
}),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("Node"),
});
await expect(
resolveCodexExecutionEngineForRun({
config: { engine: "acp", agentCommand: "/missing/codex-acp" },
executionTarget: null,
}),
).resolves.toMatchObject({ engine: "acp", explicit: true, unavailableReason: expect.stringContaining("Node") });
});
it("requires explicit CLI selection for local filesystem or network scope", async () => {
await expect(
resolveCodexExecutionEngineForRun({
config: { filesystemScope: "workspace" },
executionTarget: null,
}),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("confinement"),
});
await expect(
resolveCodexExecutionEngineForRun({
config: { engine: "acp", filesystemScope: "workspace" },
executionTarget: null,
}),
).resolves.toMatchObject({ engine: "acp", unavailableReason: expect.stringContaining("ACP confinement is not supported") });
await expect(
resolveCodexExecutionEngineForRun({
config: { networkScope: "allowlist" },
executionTarget: null,
}),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("confinement"),
});
await expect(
resolveCodexExecutionEngineForRun({
config: { filesystemScope: "workpace" },
executionTarget: null,
}),
).rejects.toThrow('filesystemScope must be "workspace"');
});
it("requires explicit CLI selection for in-place realization", async () => {
const executionTarget = {
kind: "remote" as const,
transport: "sandbox" as const,
remoteCwd: "/app",
workspaceRealization: {
mode: "in_place" as const,
authoritativeRoot: "/app",
pathAliases: [],
outboundRestorePaths: [],
},
};
await expect(
resolveCodexExecutionEngineForRun({ config: {}, executionTarget }),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("ACP archive staging"),
});
await expect(
resolveCodexExecutionEngineForRun({ config: { engine: "acp" }, executionTarget }),
).resolves.toMatchObject({ engine: "acp", unavailableReason: expect.stringContaining("In-place workspace realization requires the Codex CLI engine") });
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v24.11.0");
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: "codex-acp" },
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
},
}),
).resolves.toEqual({ engine: "acp", explicit: false });
});
it("reports unavailable ACP for one-shot sandbox auto runs", async () => {
setNodeVersion("v24.11.0");
await expect(
resolveCodexExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
},
}),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("bidirectional remote process"),
});
});
it("reports unavailable ACP for non-sandbox remote auto runs", async () => {
setNodeVersion("v24.11.0");
await expect(
resolveCodexExecutionEngineForRun({
config: {},
executionTarget: {
kind: "remote",
transport: "ssh",
remoteCwd: "/work",
spec: {
host: "127.0.0.1",
port: 22,
username: "fixture",
remoteCwd: "/work",
remoteWorkspacePath: "/work",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
}),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("sandbox remote targets only"),
});
});
it("enables workspace networking for ACP without changing other env settings", () => {
expect(buildCodexAcpConfig({ env: { CUSTOM: "kept" } })).toMatchObject({
env: { CUSTOM: "kept", PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" },
});
});
it.each([
{ env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } },
{ extraArgs: ["-c", "sandbox_workspace_write.network_access=false"] },
{ extraArgs: ["--config=sandbox_workspace_write.network_access=false"] },
{ args: ["-csandbox_workspace_write.network_access=false"] },
{ extraArgs: ["-c", "sandbox_workspace_write.network_access=true", "-c", "sandbox_workspace_write.network_access=false"] },
])("preserves explicit ACP network denial %j", (config) => {
expect(buildCodexAcpConfig(config)).toMatchObject({
env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" },
});
});
it("maps Codex config to the ACPX Codex target", () => {
expect(buildCodexAcpConfig({
engine: "acp",
cwd: "/repo",
command: "codex",
model: "gpt-5.5",
modelReasoningEffort: "high",
fastMode: true,
agentCommand: "custom-codex-acp",
warmHandleIdleMs: 25,
})).toMatchObject({
agent: "codex",
cwd: "/repo",
command: "codex",
model: "gpt-5.5",
modelReasoningEffort: "high",
fastMode: true,
agentCommand: "custom-codex-acp",
mode: "persistent",
permissionMode: "approve-all",
nonInteractivePermissions: "deny",
warmHandleIdleMs: 25,
});
});
it("forwards GPT-6 Astra controls to the ACPX Codex target", () => {
expect(buildCodexAcpConfig({
engine: "acp",
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
fastMode: true,
})).toMatchObject({
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
fastMode: true,
});
});
it("normalizes the legacy bare gpt-5.6 alias to gpt-5.6-sol", () => {
expect(buildCodexAcpConfig({ engine: "acp", model: "gpt-5.6" })).toMatchObject({
model: "gpt-5.6-sol",
});
});
it("checks the Node version required by the ACPX runtime", () => {
setNodeVersion("v24.10.0");
expect(nodeVersionMeetsCodexAcpMinimum()).toBe(false);
setNodeVersion("v24.11.0");
expect(nodeVersionMeetsCodexAcpMinimum()).toBe(true);
});
it("reports ACP prerequisites for the ACP lane", async () => {
const root = await makeTempRoot("paperclip-codex-acp-env-");
const commandPath = path.join(root, "bin", "codex-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v24.11.0");
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
agentCommand: commandPath,
env: { OPENAI_API_KEY: "test-key" },
},
});
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_engine_selected",
level: "info",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_acp_command_resolvable",
level: "info",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_acp_runtime_scaffold",
level: "info",
}),
);
});
it("detects shared managed Codex auth in ACP environment tests", async () => {
const root = await makeTempRoot("paperclip-codex-acp-managed-auth-");
const commandPath = path.join(root, "bin", "codex-acp");
const sharedCodexHome = path.join(root, "shared-codex-home");
const managedAgentHome = path.join(
root,
"paperclip-home",
"instances",
"test",
"companies",
"company-1",
"agents",
"agent-1",
"codex-home",
);
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"OPENAI_API_KEY":"sk-shared"}', "utf8");
setNodeVersion("v24.11.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
agentCommand: commandPath,
env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" },
},
});
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_acp_native_auth_detected",
level: "info",
detail: expect.stringContaining(sharedCodexHome),
}),
);
expect(result.checks).not.toContainEqual(
expect.objectContaining({
code: "codex_acp_credentials_missing",
}),
);
});
it("explains the Paperclip server credential boundary when ACP auth is missing", async () => {
const root = await makeTempRoot("paperclip-codex-acp-missing-auth-");
const commandPath = path.join(root, "bin", "codex-acp");
const sharedCodexHome = path.join(root, "shared-codex-home");
const managedAgentHome = path.join(
root,
"paperclip-home",
"instances",
"test",
"companies",
"company-1",
"agents",
"agent-1",
"codex-home",
);
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
await fs.mkdir(sharedCodexHome, { recursive: true });
setNodeVersion("v24.11.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
agentCommand: commandPath,
env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" },
},
});
expect(result.status).toBe("warn");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "codex_acp_credentials_missing",
level: "warn",
message: expect.stringContaining("Paperclip server"),
hint: expect.stringContaining("separate Codex/chat session"),
}),
);
});
it("emits the canonical adapter_auth_missing check for a missing-auth sandbox target", async () => {
const root = await makeTempRoot("paperclip-codex-acp-sandbox-missing-auth-");
const sharedCodexHome = path.join(root, "shared-codex-home");
const managedAgentHome = path.join(
root,
"paperclip-home",
"instances",
"test",
"companies",
"company-1",
"agents",
"agent-1",
"codex-home",
);
await fs.mkdir(sharedCodexHome, { recursive: true });
setNodeVersion("v24.11.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
// A shell-style command resolves without a real binary in the sandbox.
agentCommand: "node ./fake-acp.js",
env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
} as never,
});
// A missing-auth sandbox is a warning, not a failure, and it carries the
// neutral canonical check code for the user interface.
expect(result.status).toBe("warn");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "adapter_auth_missing",
level: "warn",
}),
);
});
it("executes through ACPX with Codex session config and ephemeral skills", async () => {
const root = await makeTempRoot("paperclip-codex-acp-exec-");
const skill = await createRuntimeSkill(root);
const runtimes: FakeRuntime[] = [];
const meta: AdapterInvocationMeta[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const result = await execute(buildContext(root, {
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
env: {
CODEX_HOME: path.join(root, "codex-home"),
},
model: "gpt-5.5",
modelReasoningEffort: "high",
fastMode: true,
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [skill],
paperclipSkillSync: { desiredSkills: [skill.key] },
},
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}));
expect(result.exitCode).toBe(0);
expect(result.sessionParams).toMatchObject({
agent: "codex",
mode: "persistent",
acpSessionId: "acp-1",
workspaceId: "workspace-1",
});
expect(result.sessionParams?.skills).toMatchObject({
mode: "codex",
selectedSkills: ["review"],
});
const skillsHome = (result.sessionParams?.skills as { skillsHome?: string }).skillsHome;
expect(skillsHome).toBeTruthy();
await expect(fs.readFile(path.join(skillsHome!, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
expect(runtimes[0]?.ensureInputs[0]).toMatchObject({
agent: "codex",
mode: "persistent",
cwd: root,
});
expect(runtimes[0]?.setConfigInputs).toEqual([]);
expect(meta[0]?.commandNotes?.join("\n")).toContain("Prepared ACPX Codex skill home");
expect(meta[0]?.env?.CODEX_HOME).toBe(path.join(root, "codex-home"));
expect(JSON.parse(String(meta[0]?.env?.CODEX_CONFIG))).toEqual({
model: "gpt-5.5",
model_reasoning_effort: "high",
service_tier: "fast",
features: { fast_mode: true },
});
});
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
const root = await makeTempRoot("paperclip-codex-acp-remote-cwd-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const runtimes: FakeRuntime[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
// Use a throwaway ACP command so the process-session bridge does not
// require a real codex-acp binary in the local sandbox stand-in.
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: path.join(root, "codex-home") },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipTaskMarkdown: "Task context",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
// The workspace was shipped into the sandbox and session/new was created on
// the in-sandbox workspace dir — not the HOST worktree path.
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
expect(runtimes[0]?.ensureInputs[0]?.cwd).toBe(remoteCwd);
expect(runtimes[0]?.ensureInputs[0]?.cwd).not.toBe(localCwd);
});
it("seeds the managed Codex home into the sandbox and repoints CODEX_HOME to the in-sandbox path", async () => {
const root = await makeTempRoot("paperclip-codex-acp-home-seed-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
// A separate shared host home with no auth.json so the teardown copy-back is
// a benign no-op here (this test asserts the inbound seed + remap only).
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-seed", NEWER_REFRESH, "seed"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const meta: AdapterInvocationMeta[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remappedCodexHome = String(meta[0]?.env?.CODEX_HOME ?? "");
// C2 — the managed home was repointed onto an in-sandbox path, distinct from
// the host managed home; it is NOT the host CODEX_HOME.
expect(remappedCodexHome).not.toBe(sourceHome);
expect(remappedCodexHome).not.toBe(sharedHostHome);
expect(remappedCodexHome).toContain(".paperclip-runtime");
// Seeded: the credential materialized into the in-sandbox home (the local
// runner uses the host FS, so the in-sandbox path is a real host path).
await expect(fs.readFile(path.join(remappedCodexHome, "auth.json"), "utf8")).resolves.toContain(
"account_id",
);
// C4 — no XDG_* variable is introduced for in-sandbox credential discovery.
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
});
it("copies a strictly-newer sandbox Codex auth back to the shared host on teardown", async () => {
const root = await makeTempRoot("paperclip-codex-acp-copyback-newer-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
// The home staged into the sandbox carries a strictly-newer, same-identity
// credential (simulating an in-sandbox token rotation); the shared host copy
// is older.
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
// C5 — copy-back fired on teardown and installed the strictly-newer sandbox
// credential onto the shared host under the merge-lock / monotonic guard.
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer");
// Mode preserved at 0600 by the atomic same-directory rename.
const mode = (await fs.stat(path.join(sharedHostHome, "auth.json"))).mode & 0o777;
expect(mode).toBe(0o600);
});
it("keeps the shared host Codex auth when the sandbox copy is not strictly newer", async () => {
const root = await makeTempRoot("paperclip-codex-acp-copyback-older-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
// The staged/sandbox credential is OLDER than the shared host copy: the
// strictly-newer guard must keep the host credential (never overwrite a good
// token with a spent one).
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "sandbox-older"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "host-newer"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-host-newer");
});
it("keeps the host staged Codex home after a clean teardown so a compatible resume can reuse it", async () => {
// Session-re-staging guardrail: the per-run copy-back (`teardown`) must NOT
// remove the host staged-home temp dir — that removal moved to the one-time
// `disposeStaged`, fired only when the runtime is dropped. So after a CLEAN
// turn the engine caches the staged runtime warm and its host staged home is
// still on disk for the next compatible resume to reuse.
const runId = `run-keep-staged-home-${randomUUID()}`;
const root = await makeTempRoot("paperclip-codex-acp-keep-staged-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
// Strictly-newer sandbox credential so the per-run copy-back has real work.
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
// Isolated staged-runtime cache so this test observes only its own entry.
const stagedRuntimes = new Map();
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
stagedRuntimes,
stagingLocks: new Map(),
});
const result = await execute(
buildContext(localCwd, {
runId,
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
// Guardrail: `teardown` ran the copy-back but left the host staged home in
// place, and the clean turn cached the staged runtime warm for reuse.
const stagedDirs = await listCodexHomeSyncDirs(runId);
expect(stagedDirs).toHaveLength(1);
await expect(fs.stat(stagedDirs[0]!)).resolves.toBeDefined();
expect(stagedRuntimes.size).toBe(1);
// The per-run copy-back still fired: the strictly-newer sandbox credential
// landed on the shared host under the monotonic guard.
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer");
// No `disposeStaged` fires while the entry stays warm, so remove the
// intentionally-persisted staged temp ourselves to avoid leaking it.
await Promise.all(stagedDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it("removes the host staged Codex home when a failed turn drops the staged runtime", async () => {
// The complementary guardrail: when the staged runtime IS dropped (here, a
// failed turn), the one-time `disposeStaged` fires and removes the host
// staged-home temp dir — while the per-run copy-back (`teardown`) STILL fires
// on the unclean exit path, so a rotated sandbox credential is never lost.
const runId = `run-drop-staged-home-${randomUUID()}`;
const root = await makeTempRoot("paperclip-codex-acp-drop-staged-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const stagedRuntimes = new Map();
const execute = createCodexAcpExecutor({
// A failed turn drives the drop path (discard staged runtime + dispose).
createRuntime: (options: FakeRuntimeOptions) =>
new FakeRuntime(options, [], { status: "failed", stopReason: "error" }) as never,
stagedRuntimes,
stagingLocks: new Map(),
});
const result = await execute(
buildContext(localCwd, {
runId,
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(1);
// Guardrail: the dropped staged runtime disposed its host staged home and
// left nothing cached for reuse.
await expect(listCodexHomeSyncDirs(runId)).resolves.toEqual([]);
expect(stagedRuntimes.size).toBe(0);
// ...yet the per-run copy-back still ran on the failure teardown path, so the
// strictly-newer sandbox credential was not lost.
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer");
});
it("passes the Codex-specific teardown messages to the shared workspace-restore-teardown factory", async () => {
// Wiring test only: `createWorkspaceRestoreTeardown` owns the
// classify-and-redact contract, proven once for all three adapters by its
// own table-driven test in `packages/adapter-utils`. This test proves only
// that the Codex adapter passes its own two message strings to it.
// Clear the shared, hoisted mock first: earlier tests in this file also
// call through it, and a leftover call could hide a real wiring bug.
mockCreateWorkspaceRestoreTeardown.mockClear();
const root = await makeTempRoot("paperclip-codex-acp-teardown-wiring-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
stagedRuntimes: new Map(),
stagingLocks: new Map(),
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: path.join(root, "codex-home") },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
expect(mockCreateWorkspaceRestoreTeardown).toHaveBeenCalledWith(
expect.objectContaining({
startMessage: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n",
failurePrefix: "[paperclip] Codex ACP teardown restore/copy-back failed",
}),
);
});
it("reports unavailable ACP for a runner-less sandbox even when the ACP command is set", async () => {
setNodeVersion("v24.11.0");
// Isolate the missing bidirectional runner as the sole fallback cause:
// provide a valid ACP command and Node version so the only difference from
// the runner-backed ACP case is the absent `runner`.
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: "codex-acp" },
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
},
}),
).resolves.toMatchObject({
engine: "acp",
explicit: false,
unavailableReason: expect.stringContaining("bidirectional remote process"),
});
});
it("classifies ACP refresh-token auth failures", async () => {
const root = await makeTempRoot("paperclip-codex-acp-refresh-token-");
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(
options,
[],
{
status: "failed",
error: { message: "OAuth failed: refresh_token_invalidated" },
} as unknown as FakeRuntimeTurnResult,
) as never,
});
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("refresh_token_invalidated");
expect(result.errorFamily).toBe("refresh_token_invalidated");
expect(result.resultJson?.errorFamily).toBe("refresh_token_invalidated");
expect(result.resultJson).not.toHaveProperty("codexCredentialTelemetry");
});
it("resumes compatible ACP sessions on later Codex ACP runs", async () => {
const root = await makeTempRoot("paperclip-codex-acp-resume-");
const runtimes: FakeRuntime[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => {
const runtime = new FakeRuntime(options);
runtimes.push(runtime);
return runtime as never;
},
});
const first = await execute(buildContext(root));
const second = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
sessionParams: first.sessionParams ?? null,
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
}));
expect(second.exitCode).toBe(0);
expect(runtimes).toHaveLength(2);
expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1");
});
});
describe("resolveCodexAcpBillingIdentity", () => {
const originalOpenAiKey = process.env.OPENAI_API_KEY;
const originalOpenRouterKey = process.env.OPENROUTER_API_KEY;
afterEach(() => {
if (originalOpenAiKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalOpenAiKey;
if (originalOpenRouterKey === undefined) delete process.env.OPENROUTER_API_KEY;
else process.env.OPENROUTER_API_KEY = originalOpenRouterKey;
});
it("classifies an adapter-config API key as api billing to openai", () => {
delete process.env.OPENROUTER_API_KEY;
expect(
resolveCodexAcpBillingIdentity({ config: { env: { OPENAI_API_KEY: "sk-test" } } }),
).toEqual({ provider: "openai", biller: "openai", billingType: "api" });
});
it("falls back to chatgpt subscription without an API key", () => {
delete process.env.OPENAI_API_KEY;
delete process.env.OPENROUTER_API_KEY;
expect(resolveCodexAcpBillingIdentity({ config: {} })).toEqual({
provider: "openai",
biller: "chatgpt",
billingType: "subscription",
});
});
it("bills OpenRouter-backed runs to openrouter", () => {
expect(
resolveCodexAcpBillingIdentity({
config: { env: { OPENAI_API_KEY: "sk-test", OPENROUTER_API_KEY: "or-test" } },
}),
).toEqual({ provider: "openai", biller: "openrouter", billingType: "api" });
});
it("ignores host env for remote execution targets", () => {
process.env.OPENAI_API_KEY = "sk-host-only";
expect(
resolveCodexAcpBillingIdentity({
config: {},
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/work" },
} as never).billingType,
).toBe("subscription");
});
});