1357 lines
48 KiB
TypeScript
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");
|
|
});
|
|
});
|