@penclipai/adapter-utils 2026.704.0 → 2026.714.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/dist/acpx-engine/cli.d.ts +2 -0
  3. package/dist/acpx-engine/cli.d.ts.map +1 -0
  4. package/dist/acpx-engine/cli.js +128 -0
  5. package/dist/acpx-engine/cli.js.map +1 -0
  6. package/dist/acpx-engine/constants.d.ts +16 -0
  7. package/dist/acpx-engine/constants.d.ts.map +1 -0
  8. package/dist/acpx-engine/constants.js +18 -0
  9. package/dist/acpx-engine/constants.js.map +1 -0
  10. package/dist/acpx-engine/execute.d.ts +57 -0
  11. package/dist/acpx-engine/execute.d.ts.map +1 -0
  12. package/dist/acpx-engine/execute.js +1849 -0
  13. package/dist/acpx-engine/execute.js.map +1 -0
  14. package/dist/acpx-engine/execute.test.d.ts +2 -0
  15. package/dist/acpx-engine/execute.test.d.ts.map +1 -0
  16. package/dist/acpx-engine/execute.test.js +1332 -0
  17. package/dist/acpx-engine/execute.test.js.map +1 -0
  18. package/dist/acpx-engine/index.d.ts +6 -0
  19. package/dist/acpx-engine/index.d.ts.map +1 -0
  20. package/dist/acpx-engine/index.js +6 -0
  21. package/dist/acpx-engine/index.js.map +1 -0
  22. package/dist/acpx-engine/session-codec.d.ts +3 -0
  23. package/dist/acpx-engine/session-codec.d.ts.map +1 -0
  24. package/dist/acpx-engine/session-codec.js +48 -0
  25. package/dist/acpx-engine/session-codec.js.map +1 -0
  26. package/dist/acpx-engine/ui.d.ts +3 -0
  27. package/dist/acpx-engine/ui.d.ts.map +1 -0
  28. package/dist/acpx-engine/ui.js +162 -0
  29. package/dist/acpx-engine/ui.js.map +1 -0
  30. package/dist/execution-target-sandbox.test.js +416 -5
  31. package/dist/execution-target-sandbox.test.js.map +1 -1
  32. package/dist/execution-target.d.ts +37 -1
  33. package/dist/execution-target.d.ts.map +1 -1
  34. package/dist/execution-target.js +488 -9
  35. package/dist/execution-target.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/sandbox-callback-bridge.d.ts.map +1 -1
  40. package/dist/sandbox-callback-bridge.js +9 -4
  41. package/dist/sandbox-callback-bridge.js.map +1 -1
  42. package/dist/sandbox-callback-bridge.test.js +4 -0
  43. package/dist/sandbox-callback-bridge.test.js.map +1 -1
  44. package/dist/server-utils.d.ts +18 -0
  45. package/dist/server-utils.d.ts.map +1 -1
  46. package/dist/server-utils.js +85 -19
  47. package/dist/server-utils.js.map +1 -1
  48. package/dist/server-utils.test.js +256 -8
  49. package/dist/server-utils.test.js.map +1 -1
  50. package/dist/session-compaction.d.ts.map +1 -1
  51. package/dist/session-compaction.js +0 -6
  52. package/dist/session-compaction.js.map +1 -1
  53. package/dist/types.d.ts +35 -1
  54. package/dist/types.d.ts.map +1 -1
  55. package/package.json +5 -1
@@ -0,0 +1,1332 @@
1
+ import fs from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC } from "@penclipai/adapter-utils/execution-target";
9
+ import { createAcpxEngineExecutor, findAncestorBin, geminiVersionSupportsNativeAcpFlag, parseGeminiVersionParts, rewriteGeminiAcpFlagForVersion, summarizeAcpxTurnUsage, } from "./execute.js";
10
+ import { runChildProcess } from "../server-utils.js";
11
+ const execFileAsync = promisify(execFile);
12
+ const tempRoots = [];
13
+ async function makeTempRoot() {
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
15
+ tempRoots.push(root);
16
+ return root;
17
+ }
18
+ afterEach(async () => {
19
+ await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, {
20
+ recursive: true,
21
+ force: true,
22
+ maxRetries: 10,
23
+ retryDelay: 50,
24
+ })));
25
+ });
26
+ async function pathExists(candidate) {
27
+ return fs.access(candidate).then(() => true).catch(() => false);
28
+ }
29
+ async function onlyChildDir(parent) {
30
+ const entries = await fs.readdir(parent);
31
+ expect(entries).toHaveLength(1);
32
+ return path.join(parent, entries[0]);
33
+ }
34
+ async function createSkill(root, name, body = `---\nrequired: false\n---\n# ${name}\n`) {
35
+ const skillDir = path.join(root, name);
36
+ await fs.mkdir(skillDir, { recursive: true });
37
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), body, "utf8");
38
+ return {
39
+ key: `paperclipai/test/${name}`,
40
+ runtimeName: name,
41
+ source: skillDir,
42
+ required: false,
43
+ };
44
+ }
45
+ function resolveTestPosixShellCommand(command) {
46
+ if (process.platform !== "win32")
47
+ return command === "bash" ? "/bin/bash" : "sh";
48
+ const candidates = command === "bash"
49
+ ? ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files\\Git\\usr\\bin\\bash.exe"]
50
+ : ["C:\\Program Files\\Git\\usr\\bin\\sh.exe", "C:\\Program Files\\Git\\bin\\bash.exe"];
51
+ return candidates.find((candidate) => existsSync(candidate)) ?? command;
52
+ }
53
+ function createChildProcessEnv(overrides) {
54
+ const env = {};
55
+ for (const [key, value] of Object.entries(process.env)) {
56
+ if (value !== undefined)
57
+ env[key] = value;
58
+ }
59
+ return { ...env, ...overrides };
60
+ }
61
+ function augmentTestPosixPath(env) {
62
+ if (process.platform !== "win32")
63
+ return;
64
+ const entries = [
65
+ "/usr/bin",
66
+ "/bin",
67
+ "C:\\Program Files\\Git\\usr\\bin",
68
+ "C:\\Program Files\\Git\\bin",
69
+ ].filter((entry) => entry.startsWith("/") || existsSync(entry));
70
+ env.PATH = [...entries, env.PATH ?? ""].join(path.delimiter);
71
+ }
72
+ function rewriteWindowsPathsForGitShell(script) {
73
+ if (process.platform !== "win32")
74
+ return script;
75
+ return script.replace(/([A-Za-z]):\\([^'"\s]*)/g, (_match, drive, rest) => `/${drive.toLowerCase()}/${rest.replace(/\\/g, "/")}`);
76
+ }
77
+ function createLocalSandboxRunner(onExecute) {
78
+ let counter = 0;
79
+ return {
80
+ execute: async (input) => {
81
+ counter += 1;
82
+ await onExecute?.(input);
83
+ const usesPosixShell = input.command === "bash" || input.command === "sh";
84
+ const command = usesPosixShell
85
+ ? resolveTestPosixShellCommand(input.command)
86
+ : input.command;
87
+ const args = [...(input.args ?? [])];
88
+ const env = createChildProcessEnv(input.env);
89
+ if (usesPosixShell) {
90
+ augmentTestPosixPath(env);
91
+ if ((args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string") {
92
+ args[1] = rewriteWindowsPathsForGitShell(args[1]);
93
+ }
94
+ }
95
+ return await runChildProcess(`acpx-sandbox-run-${counter}`, command, args, {
96
+ cwd: input.cwd ?? process.cwd(),
97
+ env,
98
+ stdin: input.stdin,
99
+ timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
100
+ graceSec: 5,
101
+ onLog: input.onLog ?? (async () => { }),
102
+ onSpawn: input.onSpawn
103
+ ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
104
+ : undefined,
105
+ });
106
+ },
107
+ };
108
+ }
109
+ function buildRuntime() {
110
+ return {
111
+ ensureSession: async () => ({
112
+ backendSessionId: "backend-session",
113
+ agentSessionId: "agent-session",
114
+ runtimeSessionName: "runtime-session",
115
+ }),
116
+ startTurn: () => ({
117
+ events: (async function* () {
118
+ yield { type: "done", stopReason: "end_turn" };
119
+ })(),
120
+ result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
121
+ cancel: async () => { },
122
+ }),
123
+ close: async () => { },
124
+ };
125
+ }
126
+ async function runExecutor(config, options = {}) {
127
+ const runtimeOptions = [];
128
+ const meta = [];
129
+ const logs = [];
130
+ const execute = createAcpxEngineExecutor({
131
+ createRuntime: (options) => {
132
+ runtimeOptions.push(options);
133
+ return buildRuntime();
134
+ },
135
+ });
136
+ const result = await execute({
137
+ runId: "run-1",
138
+ agent: {
139
+ id: "agent-1",
140
+ companyId: "company-1",
141
+ },
142
+ runtime: {},
143
+ config,
144
+ context: options.context ?? {},
145
+ executionTransport: options.executionTransport,
146
+ authToken: options.authToken,
147
+ executionTarget: options.executionTarget,
148
+ onLog: async (stream, text) => {
149
+ logs.push({ stream, text });
150
+ },
151
+ onMeta: async (payload) => {
152
+ meta.push(payload);
153
+ },
154
+ });
155
+ expect(result.exitCode).toBe(0);
156
+ return { logs, meta, runtimeOptions, result };
157
+ }
158
+ describe("shared ACPX engine runtime behavior", () => {
159
+ it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => {
160
+ const { meta } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js" }, {
161
+ authToken: "runtime-secret-token",
162
+ context: {
163
+ taskId: "issue-1",
164
+ wakeReason: "issue_assigned",
165
+ paperclipWake: {
166
+ reason: "issue_assigned",
167
+ issue: { id: "issue-1", identifier: "TEST-1" },
168
+ },
169
+ },
170
+ });
171
+ const prompt = String(meta[0]?.prompt ?? "");
172
+ const promptMetrics = meta[0]?.promptMetrics;
173
+ expect(prompt).toContain("Paperclip runtime note:");
174
+ expect(prompt).toContain("PAPERCLIP_AGENT_ID");
175
+ expect(prompt).toContain("PAPERCLIP_API_KEY");
176
+ expect(prompt).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
177
+ expect(prompt).toContain("Paperclip API access note:");
178
+ expect(prompt).toContain('PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"');
179
+ expect(prompt).toContain("$PAPERCLIP_API_BASE/api/agents/me");
180
+ expect(prompt).toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
181
+ expect(prompt).toContain("X-Paperclip-Run-Id");
182
+ expect(prompt).not.toContain("$PAPERCLIP_API_URL/api/");
183
+ expect(prompt).not.toContain("/api/issues/{id}");
184
+ expect(prompt).not.toContain("-d '{...}'");
185
+ expect(prompt).not.toContain("runtime-secret-token");
186
+ expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
187
+ });
188
+ it("does not show a scoped issue API command when the task id is unavailable", async () => {
189
+ const { meta } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js" }, { authToken: "runtime-secret-token" });
190
+ const prompt = String(meta[0]?.prompt ?? "");
191
+ expect(prompt).toContain("Paperclip API access note:");
192
+ expect(prompt).toContain("Use a real issue id from the current context before making issue write requests.");
193
+ expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
194
+ });
195
+ it("emits ACP text deltas as stdout transcript records", async () => {
196
+ const root = await makeTempRoot();
197
+ const stateDir = path.join(root, "state");
198
+ const logs = [];
199
+ const execute = createAcpxEngineExecutor({
200
+ createRuntime: () => ({
201
+ ensureSession: async () => ({
202
+ backendSessionId: "backend-session",
203
+ agentSessionId: "agent-session",
204
+ runtimeSessionName: "runtime-session",
205
+ }),
206
+ startTurn: () => ({
207
+ events: (async function* () {
208
+ yield {
209
+ type: "text_delta",
210
+ text: "streamed hello",
211
+ stream: "output",
212
+ tag: "agent_message_chunk",
213
+ };
214
+ yield { type: "done", stopReason: "end_turn" };
215
+ })(),
216
+ result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
217
+ cancel: async () => { },
218
+ }),
219
+ close: async () => { },
220
+ }),
221
+ });
222
+ const result = await execute({
223
+ runId: "run-streaming-text-delta",
224
+ agent: {
225
+ id: "agent-1",
226
+ companyId: "company-1",
227
+ },
228
+ runtime: {},
229
+ config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
230
+ context: {},
231
+ onLog: async (stream, text) => {
232
+ logs.push({ stream, text });
233
+ },
234
+ onMeta: async () => { },
235
+ });
236
+ expect(result.exitCode).toBe(0);
237
+ expect(logs).toContainEqual({
238
+ stream: "stdout",
239
+ text: `${JSON.stringify({
240
+ type: "acpx.text_delta",
241
+ text: "streamed hello",
242
+ channel: "output",
243
+ tag: "agent_message_chunk",
244
+ })}\n`,
245
+ });
246
+ });
247
+ it("captures per-run usage, cost deltas, and billing identity from the ACP runtime", async () => {
248
+ const root = await makeTempRoot();
249
+ const stateDir = path.join(root, "state");
250
+ const logs = [];
251
+ let statusCalls = 0;
252
+ const execute = createAcpxEngineExecutor({
253
+ createRuntime: () => ({
254
+ ensureSession: async () => ({
255
+ backendSessionId: "backend-session",
256
+ agentSessionId: "agent-session",
257
+ runtimeSessionName: "runtime-session",
258
+ }),
259
+ getStatus: async () => {
260
+ statusCalls += 1;
261
+ return statusCalls === 1
262
+ ? { usage: { cost: { amount: 0.4, currency: "USD" } } }
263
+ : {
264
+ usage: {
265
+ cumulative: {
266
+ inputTokens: 120,
267
+ outputTokens: 4500,
268
+ cachedReadTokens: 900,
269
+ cachedWriteTokens: 30,
270
+ },
271
+ cost: { amount: 1.15, currency: "USD" },
272
+ },
273
+ };
274
+ },
275
+ startTurn: () => ({
276
+ events: (async function* () {
277
+ yield {
278
+ type: "status",
279
+ text: "usage",
280
+ tag: "usage_update",
281
+ used: 5550,
282
+ size: 200000,
283
+ cost: { amount: 1.1, currency: "USD" },
284
+ };
285
+ yield { type: "done", stopReason: "end_turn" };
286
+ })(),
287
+ result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
288
+ cancel: async () => { },
289
+ }),
290
+ close: async () => { },
291
+ }),
292
+ resolveBillingIdentity: () => ({ provider: "anthropic", biller: "anthropic", billingType: "api" }),
293
+ });
294
+ const result = await execute({
295
+ runId: "run-usage-capture",
296
+ agent: {
297
+ id: "agent-1",
298
+ companyId: "company-1",
299
+ },
300
+ runtime: {},
301
+ config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
302
+ context: {},
303
+ onLog: async (stream, text) => {
304
+ logs.push({ stream, text });
305
+ },
306
+ onMeta: async () => { },
307
+ });
308
+ expect(result.exitCode).toBe(0);
309
+ expect(statusCalls).toBe(2);
310
+ // Cache-write tokens count as input tokens; cached reads stay separate.
311
+ expect(result.usage).toEqual({ inputTokens: 150, outputTokens: 4500, cachedInputTokens: 900 });
312
+ expect(result.usageBasis).toBe("per_run");
313
+ // Agent-reported cost is cumulative; this run pays the delta.
314
+ expect(result.costUsd).toBeCloseTo(0.75);
315
+ expect(result.provider).toBe("anthropic");
316
+ expect(result.biller).toBe("anthropic");
317
+ expect(result.billingType).toBe("api");
318
+ expect(result.resultJson?.cumulativeCostUsd).toBeCloseTo(1.15);
319
+ expect(result.resultJson?.usage).toEqual({
320
+ inputTokens: 120,
321
+ outputTokens: 4500,
322
+ cachedReadTokens: 900,
323
+ cachedWriteTokens: 30,
324
+ });
325
+ const statusLine = logs.find((entry) => entry.text.includes('"acpx.status"'));
326
+ expect(statusLine?.text).toContain('"cost"');
327
+ });
328
+ it("falls back to usage_update events when the runtime lacks getStatus", async () => {
329
+ const root = await makeTempRoot();
330
+ const stateDir = path.join(root, "state");
331
+ const execute = createAcpxEngineExecutor({
332
+ createRuntime: () => ({
333
+ ensureSession: async () => ({
334
+ backendSessionId: "backend-session",
335
+ agentSessionId: "agent-session",
336
+ runtimeSessionName: "runtime-session",
337
+ }),
338
+ startTurn: () => ({
339
+ events: (async function* () {
340
+ yield {
341
+ type: "status",
342
+ text: "usage",
343
+ tag: "usage_update",
344
+ cost: { amount: 0.31, currency: "USD" },
345
+ breakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
346
+ };
347
+ yield { type: "done", stopReason: "end_turn" };
348
+ })(),
349
+ result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
350
+ cancel: async () => { },
351
+ }),
352
+ close: async () => { },
353
+ }),
354
+ });
355
+ const result = await execute({
356
+ runId: "run-usage-event-fallback",
357
+ agent: {
358
+ id: "agent-1",
359
+ companyId: "company-1",
360
+ },
361
+ runtime: {},
362
+ config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
363
+ context: {},
364
+ onLog: async () => { },
365
+ onMeta: async () => { },
366
+ });
367
+ expect(result.exitCode).toBe(0);
368
+ expect(result.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
369
+ expect(result.usageBasis).toBe("per_run");
370
+ expect(result.costUsd).toBeCloseTo(0.31);
371
+ expect(result.provider).toBe("acpx");
372
+ expect(result.billingType).toBe("unknown");
373
+ });
374
+ it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => {
375
+ const root = await makeTempRoot();
376
+ const skillRoot = path.join(root, "skills");
377
+ const outsideRoot = path.join(root, "outside");
378
+ await fs.mkdir(outsideRoot, { recursive: true });
379
+ await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8");
380
+ const skill = await createSkill(skillRoot, "danger");
381
+ await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(skill.source, "leak.txt"));
382
+ await fs.symlink(outsideRoot, path.join(skill.source, "leak-dir"));
383
+ const stateDir = path.join(root, "state");
384
+ const { meta } = await runExecutor({
385
+ agent: "claude",
386
+ stateDir,
387
+ paperclipRuntimeSkills: [skill],
388
+ paperclipSkillSync: { desiredSkills: [skill.key] },
389
+ });
390
+ const mountedRoot = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
391
+ const skillsHome = path.join(mountedRoot, ".claude", "skills");
392
+ const materializedSkill = path.join(skillsHome, skill.runtimeName);
393
+ expect(await fs.readFile(path.join(materializedSkill, "SKILL.md"), "utf8")).toContain("# danger");
394
+ expect(await pathExists(path.join(materializedSkill, "leak.txt"))).toBe(false);
395
+ expect(await pathExists(path.join(materializedSkill, "leak-dir"))).toBe(false);
396
+ expect(String(meta[0]?.prompt ?? "")).toContain(`Skill root: ${skillsHome}`);
397
+ });
398
+ it.skipIf(process.platform === "win32")("revokes removed ACPX Codex skills and skips symlinked descendants", async () => {
399
+ const root = await makeTempRoot();
400
+ const skillRoot = path.join(root, "skills");
401
+ const outsideRoot = path.join(root, "outside");
402
+ const codexHome = path.join(root, "codex-home");
403
+ await fs.mkdir(outsideRoot, { recursive: true });
404
+ await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8");
405
+ const keep = await createSkill(skillRoot, "keep");
406
+ const remove = await createSkill(skillRoot, "remove");
407
+ await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(keep.source, "leak.txt"));
408
+ await fs.symlink(outsideRoot, path.join(keep.source, "leak-dir"));
409
+ const baseConfig = {
410
+ agent: "codex",
411
+ stateDir: path.join(root, "state"),
412
+ env: { CODEX_HOME: codexHome },
413
+ paperclipRuntimeSkills: [keep, remove],
414
+ };
415
+ await runExecutor({
416
+ ...baseConfig,
417
+ paperclipSkillSync: { desiredSkills: [keep.key, remove.key] },
418
+ });
419
+ expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName, "SKILL.md"))).toBe(true);
420
+ await runExecutor({
421
+ ...baseConfig,
422
+ paperclipSkillSync: { desiredSkills: [keep.key] },
423
+ });
424
+ expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "SKILL.md"))).toBe(true);
425
+ expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak.txt"))).toBe(false);
426
+ expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak-dir"))).toBe(false);
427
+ expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false);
428
+ });
429
+ it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => {
430
+ const root = await makeTempRoot();
431
+ const skillRoot = path.join(root, "skills");
432
+ const codexHome = path.join(root, "codex-home");
433
+ const legacy = await createSkill(skillRoot, "legacy");
434
+ const skillsHome = path.join(codexHome, "skills");
435
+ await fs.mkdir(skillsHome, { recursive: true });
436
+ await fs.symlink(legacy.source, path.join(skillsHome, legacy.runtimeName));
437
+ await runExecutor({
438
+ agent: "codex",
439
+ stateDir: path.join(root, "state"),
440
+ env: { CODEX_HOME: codexHome },
441
+ paperclipRuntimeSkills: [legacy],
442
+ paperclipSkillSync: { desiredSkills: [] },
443
+ });
444
+ expect(await pathExists(path.join(skillsHome, legacy.runtimeName))).toBe(false);
445
+ });
446
+ it.skipIf(process.platform === "win32")("replaces stale managed Codex auth files with source symlinks", async () => {
447
+ const root = await makeTempRoot();
448
+ const sourceCodexHome = path.join(root, "source-codex-home");
449
+ const paperclipHome = path.join(root, "paperclip-home");
450
+ const paperclipInstanceId = "test-instance";
451
+ const managedCodexHome = path.join(paperclipHome, "instances", paperclipInstanceId, "companies", "company-1", "codex-home");
452
+ await fs.mkdir(sourceCodexHome, { recursive: true });
453
+ await fs.mkdir(managedCodexHome, { recursive: true });
454
+ const sourceAuth = path.join(sourceCodexHome, "auth.json");
455
+ const managedAuth = path.join(managedCodexHome, "auth.json");
456
+ await fs.writeFile(sourceAuth, "{\"source\":true}", "utf8");
457
+ await fs.writeFile(managedAuth, "{\"stale\":true}", "utf8");
458
+ const previousCodexHome = process.env.CODEX_HOME;
459
+ const previousPaperclipHome = process.env.PAPERCLIP_HOME;
460
+ const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
461
+ try {
462
+ process.env.CODEX_HOME = sourceCodexHome;
463
+ process.env.PAPERCLIP_HOME = paperclipHome;
464
+ process.env.PAPERCLIP_INSTANCE_ID = paperclipInstanceId;
465
+ await runExecutor({
466
+ agent: "codex",
467
+ stateDir: path.join(root, "state"),
468
+ paperclipRuntimeSkills: [],
469
+ paperclipSkillSync: { desiredSkills: [] },
470
+ });
471
+ }
472
+ finally {
473
+ if (previousCodexHome === undefined)
474
+ delete process.env.CODEX_HOME;
475
+ else
476
+ process.env.CODEX_HOME = previousCodexHome;
477
+ if (previousPaperclipHome === undefined)
478
+ delete process.env.PAPERCLIP_HOME;
479
+ else
480
+ process.env.PAPERCLIP_HOME = previousPaperclipHome;
481
+ if (previousPaperclipInstanceId === undefined)
482
+ delete process.env.PAPERCLIP_INSTANCE_ID;
483
+ else
484
+ process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
485
+ }
486
+ const authStat = await fs.lstat(managedAuth);
487
+ expect(authStat.isSymbolicLink()).toBe(true);
488
+ expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth);
489
+ });
490
+ it("keeps fresh credential wrapper scripts across ACPX agent changes", async () => {
491
+ const root = await makeTempRoot();
492
+ const stateDir = path.join(root, "state");
493
+ const baseConfig = {
494
+ agentCommand: "node ./fake-acp.js",
495
+ stateDir,
496
+ };
497
+ await runExecutor({
498
+ ...baseConfig,
499
+ agent: "custom-a",
500
+ env: { PAPERCLIP_API_KEY: "old-key" },
501
+ });
502
+ await runExecutor({
503
+ ...baseConfig,
504
+ agent: "custom-b",
505
+ env: { PAPERCLIP_API_KEY: "new-key" },
506
+ });
507
+ const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
508
+ expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(2);
509
+ expect(wrappers.filter((name) => name.endsWith(".env"))).toHaveLength(2);
510
+ expect(wrappers.some((name) => name.startsWith("custom-a-"))).toBe(true);
511
+ expect(wrappers.some((name) => name.startsWith("custom-b-"))).toBe(true);
512
+ const wrapperPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.startsWith("custom-b-") && name.endsWith(".sh")));
513
+ const envPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.startsWith("custom-b-") && name.endsWith(".env")));
514
+ const wrapper = await fs.readFile(wrapperPath, "utf8");
515
+ const env = await fs.readFile(envPath, "utf8");
516
+ if (process.platform !== "win32") {
517
+ expect((await fs.stat(envPath)).mode & 0o777).toBe(0o600);
518
+ expect((await fs.stat(wrapperPath)).mode & 0o777).toBe(0o700);
519
+ }
520
+ expect(wrapper).toContain("node ./fake-acp.js");
521
+ expect(wrapper).not.toContain("PAPERCLIP_API_KEY");
522
+ expect(wrapper).not.toContain("new-key");
523
+ expect(wrapper).not.toContain("old-key");
524
+ expect(env).toContain("PAPERCLIP_API_KEY='new-key'");
525
+ expect(env).not.toContain("old-key");
526
+ });
527
+ it("shapes ACPX wrapper workspace env for remote execution identities", async () => {
528
+ const root = await makeTempRoot();
529
+ const stateDir = path.join(root, "state");
530
+ const workspaceDir = path.join(root, "workspace");
531
+ await fs.mkdir(workspaceDir, { recursive: true });
532
+ await runExecutor({
533
+ agentCommand: "node ./fake-acp.js",
534
+ stateDir,
535
+ }, {
536
+ context: {
537
+ paperclipWorkspace: {
538
+ cwd: workspaceDir,
539
+ source: "project_primary",
540
+ strategy: "git_worktree",
541
+ workspaceId: "workspace-1",
542
+ repoUrl: "https://github.com/paperclipai/paperclip.git",
543
+ repoRef: "main",
544
+ branchName: "feature/remote-acpx",
545
+ worktreePath: workspaceDir,
546
+ },
547
+ },
548
+ executionTransport: {
549
+ remoteExecution: {
550
+ host: "127.0.0.1",
551
+ port: 2222,
552
+ username: "fixture",
553
+ remoteWorkspacePath: "/remote/workspace",
554
+ remoteCwd: "/remote/workspace",
555
+ privateKey: "PRIVATE KEY",
556
+ knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
557
+ strictHostKeyChecking: true,
558
+ },
559
+ },
560
+ });
561
+ const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
562
+ const envPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.endsWith(".env")));
563
+ const env = await fs.readFile(envPath, "utf8");
564
+ expect(env).toContain("PAPERCLIP_WORKSPACE_CWD='/remote/workspace'");
565
+ expect(env).not.toContain("PAPERCLIP_WORKSPACE_WORKTREE_PATH=");
566
+ });
567
+ it("cleans aged credential wrapper scripts across ACPX agent changes", async () => {
568
+ const root = await makeTempRoot();
569
+ const stateDir = path.join(root, "state");
570
+ const wrappersDir = path.join(stateDir, "wrappers");
571
+ const baseConfig = {
572
+ agentCommand: "node ./fake-acp.js",
573
+ stateDir,
574
+ };
575
+ await runExecutor({
576
+ ...baseConfig,
577
+ agent: "custom-a",
578
+ env: { PAPERCLIP_API_KEY: "old-key" },
579
+ });
580
+ const oldDate = new Date(Date.now() - 16 * 60 * 1000);
581
+ await Promise.all((await fs.readdir(wrappersDir))
582
+ .filter((name) => name.startsWith("custom-a-"))
583
+ .map((name) => fs.utimes(path.join(wrappersDir, name), oldDate, oldDate)));
584
+ await runExecutor({
585
+ ...baseConfig,
586
+ agent: "custom-b",
587
+ env: { PAPERCLIP_API_KEY: "new-key" },
588
+ });
589
+ const wrappers = await fs.readdir(wrappersDir);
590
+ expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(1);
591
+ expect(wrappers.filter((name) => name.endsWith(".env"))).toHaveLength(1);
592
+ expect(wrappers.some((name) => name.startsWith("custom-a-"))).toBe(false);
593
+ expect(wrappers.some((name) => name.startsWith("custom-b-"))).toBe(true);
594
+ });
595
+ it("keeps distinct wrapper env files for concurrent runs with different credentials", async () => {
596
+ const root = await makeTempRoot();
597
+ const stateDir = path.join(root, "state");
598
+ const baseConfig = {
599
+ agent: "custom-a",
600
+ agentCommand: "node ./fake-acp.js",
601
+ stateDir,
602
+ };
603
+ await runExecutor({
604
+ ...baseConfig,
605
+ env: { PAPERCLIP_API_KEY: "first-key" },
606
+ });
607
+ await runExecutor({
608
+ ...baseConfig,
609
+ env: { PAPERCLIP_API_KEY: "second-key" },
610
+ });
611
+ const envFileNames = (await fs.readdir(path.join(stateDir, "wrappers"))).filter((name) => name.endsWith(".env"));
612
+ expect(envFileNames).toHaveLength(2);
613
+ const envFiles = await Promise.all(envFileNames.map(async (name) => fs.readFile(path.join(stateDir, "wrappers", name), "utf8")));
614
+ expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='first-key'"))).toHaveLength(1);
615
+ expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1);
616
+ });
617
+ it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => {
618
+ const root = await makeTempRoot();
619
+ const stateDir = path.join(root, "state");
620
+ const runStderrDir = path.join(stateDir, "run-stderr");
621
+ await fs.mkdir(runStderrDir, { recursive: true });
622
+ const stderrTail = "claude-agent-acp: SDK init failed (auth missing)";
623
+ await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8");
624
+ class FakeAcpRuntimeError extends Error {
625
+ code = "ACP_SESSION_INIT_FAILED";
626
+ cause;
627
+ retryable = false;
628
+ constructor(message, cause) {
629
+ super(message);
630
+ this.name = "AcpRuntimeError";
631
+ this.cause = cause;
632
+ }
633
+ }
634
+ const logs = [];
635
+ const execute = createAcpxEngineExecutor({
636
+ createRuntime: () => ({
637
+ ensureSession: async () => {
638
+ throw new FakeAcpRuntimeError("session/new failed: backend rejected initialize", new Error("upstream timeout"));
639
+ },
640
+ startTurn: () => ({
641
+ events: (async function* () { })(),
642
+ result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
643
+ cancel: async () => { },
644
+ }),
645
+ close: async () => { },
646
+ }),
647
+ });
648
+ const result = await execute({
649
+ runId: "run-1",
650
+ agent: { id: "agent-1", companyId: "company-1" },
651
+ runtime: {},
652
+ config: {
653
+ agent: "custom",
654
+ agentCommand: "node ./fake-acp.js",
655
+ stateDir,
656
+ },
657
+ context: {},
658
+ onLog: async (stream, text) => {
659
+ logs.push({ stream, text });
660
+ },
661
+ onMeta: async () => { },
662
+ });
663
+ expect(result.exitCode).toBe(1);
664
+ expect(result.errorCode).toBe("acpx_session_init_failed");
665
+ const meta = result.errorMeta ?? {};
666
+ expect(meta.errorName).toBe("AcpRuntimeError");
667
+ expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED");
668
+ expect(meta.causeMessage).toBe("upstream timeout");
669
+ expect(meta.retryable).toBe(false);
670
+ expect(typeof meta.stackPreview).toBe("string");
671
+ expect(meta.phase).toBe("ensure_session");
672
+ const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\""));
673
+ expect(errorLogLine).toBeTruthy();
674
+ const errorPayload = JSON.parse(errorLogLine.text.trim());
675
+ expect(errorPayload.phase).toBe("ensure_session");
676
+ expect(errorPayload.errorName).toBe("AcpRuntimeError");
677
+ expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED");
678
+ expect(errorPayload.causeMessage).toBe("upstream timeout");
679
+ expect(errorPayload.childStderrTail).toContain("SDK init failed");
680
+ const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail"));
681
+ expect(stderrLog).toBeTruthy();
682
+ expect(stderrLog.text).toContain(stderrTail);
683
+ });
684
+ it("writes wrapper that redirects child stderr to a per-run log file", async () => {
685
+ const root = await makeTempRoot();
686
+ const stateDir = path.join(root, "state");
687
+ const runtimeOptions = [];
688
+ const execute = createAcpxEngineExecutor({
689
+ createRuntime: (options) => {
690
+ runtimeOptions.push(options);
691
+ return buildRuntime();
692
+ },
693
+ });
694
+ const result = await execute({
695
+ runId: "run-stderr-1",
696
+ agent: { id: "agent-1", companyId: "company-1" },
697
+ runtime: {},
698
+ config: {
699
+ agent: "custom",
700
+ agentCommand: "node ./fake-acp.js",
701
+ stateDir,
702
+ },
703
+ context: {},
704
+ onLog: async () => { },
705
+ onMeta: async () => { },
706
+ });
707
+ expect(result.exitCode).toBe(0);
708
+ const verboseFlags = runtimeOptions.map((options) => options.verbose);
709
+ // verbose is scoped to the claude agent; the custom agent here
710
+ // should not opt in to ACPX runtime verbose session-event logs.
711
+ expect(verboseFlags.every((flag) => flag === false)).toBe(true);
712
+ const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
713
+ const wrapperFile = wrappers.find((name) => name.endsWith(".sh"));
714
+ expect(wrapperFile).toBeTruthy();
715
+ const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile), "utf8");
716
+ expect(wrapper).toContain("stderr_dir=");
717
+ expect(wrapper).toContain("run-stderr");
718
+ expect(wrapper).toContain("PAPERCLIP_RUN_ID");
719
+ expect(wrapper).toContain("tee -a");
720
+ expect(wrapper).toContain("exec node ./fake-acp.js");
721
+ });
722
+ it("starts sandbox ACP process sessions in the remote execution cwd", async () => {
723
+ const root = await makeTempRoot();
724
+ const stateDir = path.join(root, "state");
725
+ const localCwd = path.join(root, "worktree");
726
+ const remoteCwd = path.join(root, "remote-workspace");
727
+ await fs.mkdir(localCwd, { recursive: true });
728
+ await fs.mkdir(remoteCwd, { recursive: true });
729
+ let sessionPayload = null;
730
+ const runner = createLocalSandboxRunner(async (input) => {
731
+ if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
732
+ const script = input.args?.[1] ?? "";
733
+ const match = script.match(/PAPERCLIP_PROCESS_SESSION_DIR='([^']+)'/);
734
+ if (match) {
735
+ sessionPayload = JSON.parse(await fs.readFile(path.join(match[1], "command.json"), "utf8"));
736
+ }
737
+ }
738
+ });
739
+ await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, {
740
+ authToken: "real-run-jwt",
741
+ executionTarget: {
742
+ kind: "remote",
743
+ transport: "sandbox",
744
+ providerKey: "fake-plugin",
745
+ remoteCwd,
746
+ runner,
747
+ },
748
+ });
749
+ expect(sessionPayload).toMatchObject({
750
+ command: "sh",
751
+ args: ["-lc", "exec node ./fake-acp.js"],
752
+ cwd: remoteCwd,
753
+ });
754
+ const payloadEnv = (sessionPayload?.env ?? {});
755
+ expect(payloadEnv).toMatchObject({
756
+ PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
757
+ });
758
+ expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
759
+ expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
760
+ expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
761
+ });
762
+ it.skipIf(process.platform === "win32")("drops benign ACP nes/close cleanup stderr but keeps it in the run log", async () => {
763
+ const root = await makeTempRoot();
764
+ const stateDir = path.join(root, "state");
765
+ const execute = createAcpxEngineExecutor({
766
+ createRuntime: () => buildRuntime(),
767
+ });
768
+ const fakeAgentPath = path.join(root, "fake-acp.sh");
769
+ await fs.writeFile(fakeAgentPath, [
770
+ "#!/usr/bin/env bash",
771
+ "echo \"Error handling request { method: 'nes/close' } { code: -32601, message: '\\\"Method not found\\\": nes/close' }\" >&2",
772
+ "echo \"some genuine crash: TypeError: x is not a function\" >&2",
773
+ "",
774
+ ].join("\n"), { mode: 0o700 });
775
+ const result = await execute({
776
+ runId: "run-nes-close-1",
777
+ agent: { id: "agent-1", companyId: "company-1" },
778
+ runtime: {},
779
+ config: {
780
+ agent: "custom",
781
+ agentCommand: fakeAgentPath,
782
+ stateDir,
783
+ },
784
+ context: {},
785
+ onLog: async () => { },
786
+ onMeta: async () => { },
787
+ });
788
+ expect(result.exitCode).toBe(0);
789
+ const wrapperFile = (await fs.readdir(path.join(stateDir, "wrappers"))).find((name) => name.endsWith(".sh"));
790
+ expect(wrapperFile).toBeTruthy();
791
+ const wrapperPath = path.join(stateDir, "wrappers", wrapperFile);
792
+ const { stderr } = await execFileAsync("bash", [wrapperPath], {
793
+ env: { ...process.env, PAPERCLIP_RUN_ID: "run-nes-close-1" },
794
+ });
795
+ expect(stderr).not.toContain("nes/close");
796
+ expect(stderr).toContain("some genuine crash: TypeError: x is not a function");
797
+ const runLog = await fs.readFile(path.join(stateDir, "run-stderr", "run-nes-close-1.log"), "utf8");
798
+ expect(runLog).toContain("nes/close");
799
+ expect(runLog).toContain("some genuine crash: TypeError: x is not a function");
800
+ });
801
+ it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => {
802
+ let observedApiKeyDuringStream;
803
+ const execute = createAcpxEngineExecutor({
804
+ createRuntime: () => ({
805
+ ensureSession: async () => ({
806
+ backendSessionId: "backend-session",
807
+ agentSessionId: "agent-session",
808
+ runtimeSessionName: "runtime-session",
809
+ }),
810
+ startTurn: () => ({
811
+ events: (async function* () {
812
+ await Promise.resolve();
813
+ observedApiKeyDuringStream = process.env.PAPERCLIP_API_KEY;
814
+ yield { type: "done", stopReason: "end_turn" };
815
+ })(),
816
+ result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
817
+ cancel: async () => { },
818
+ }),
819
+ close: async () => { },
820
+ }),
821
+ });
822
+ const previousApiKey = process.env.PAPERCLIP_API_KEY;
823
+ try {
824
+ delete process.env.PAPERCLIP_API_KEY;
825
+ const result = await execute({
826
+ runId: "run-1",
827
+ agent: {
828
+ id: "agent-1",
829
+ companyId: "company-1",
830
+ },
831
+ runtime: {},
832
+ config: { agent: "custom", agentCommand: "node ./fake-acp.js" },
833
+ context: {},
834
+ authToken: "runtime-key",
835
+ onLog: async () => { },
836
+ onMeta: async () => { },
837
+ });
838
+ expect(result.exitCode).toBe(0);
839
+ expect(observedApiKeyDuringStream).toBeUndefined();
840
+ }
841
+ finally {
842
+ if (previousApiKey === undefined)
843
+ delete process.env.PAPERCLIP_API_KEY;
844
+ else
845
+ process.env.PAPERCLIP_API_KEY = previousApiKey;
846
+ }
847
+ });
848
+ it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => {
849
+ const root = await makeTempRoot();
850
+ const stateDir = path.join(root, "state");
851
+ const cwd = path.join(root, "worktree");
852
+ await fs.mkdir(cwd, { recursive: true });
853
+ const { meta } = await runExecutor({ agent: "claude", stateDir, cwd }, { context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } });
854
+ const settingsPath = path.join(cwd, ".claude", "settings.local.json");
855
+ const written = JSON.parse(await fs.readFile(settingsPath, "utf8"));
856
+ expect(written.permissions?.defaultMode).toBe("default");
857
+ const allow = written.permissions?.allow;
858
+ expect(Array.isArray(allow)).toBe(true);
859
+ expect(allow).toContain("Bash(curl:*)");
860
+ expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`);
861
+ const additionalDirectories = written.permissions?.additionalDirectories;
862
+ expect(Array.isArray(additionalDirectories)).toBe(true);
863
+ expect(additionalDirectories).toContain(stateDir);
864
+ expect(additionalDirectories).toContain(path.join(root, "agent-home"));
865
+ const note = meta[0]?.commandNotes?.find((entry) => entry.includes("Paperclip-managed Claude settings"));
866
+ expect(note).toBeTruthy();
867
+ });
868
+ it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
869
+ const root = await makeTempRoot();
870
+ const stateDir = path.join(root, "state");
871
+ const cwd = path.join(root, "worktree");
872
+ await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
873
+ await fs.writeFile(path.join(cwd, ".claude", "settings.local.json"), JSON.stringify({
874
+ statusLine: { type: "command", command: "preserve-me" },
875
+ permissions: {
876
+ allow: ["Bash(npm test:*)"],
877
+ additionalDirectories: ["/Users/example/custom"],
878
+ defaultMode: "acceptEdits",
879
+ },
880
+ }, null, 2), "utf8");
881
+ await runExecutor({ agent: "claude", stateDir, cwd }, { context: { paperclipWorkspace: { cwd } } });
882
+ const written = JSON.parse(await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"));
883
+ expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" });
884
+ expect(written.permissions?.defaultMode).toBe("acceptEdits");
885
+ expect(written.permissions?.allow).toContain("Bash(npm test:*)");
886
+ expect(written.permissions?.allow).toContain("Bash(curl:*)");
887
+ expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom");
888
+ expect(written.permissions?.additionalDirectories).toContain(stateDir);
889
+ });
890
+ it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => {
891
+ const root = await makeTempRoot();
892
+ const stateDir = path.join(root, "state");
893
+ const cwd = path.join(root, "worktree");
894
+ await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
895
+ await fs.writeFile(path.join(cwd, ".claude", "settings.local.json"), JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2), "utf8");
896
+ const { meta } = await runExecutor({ agent: "claude", stateDir, cwd }, { context: { paperclipWorkspace: { cwd } } });
897
+ const written = JSON.parse(await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"));
898
+ expect(written.permissions?.defaultMode).toBe("default");
899
+ const overrideNote = meta[0]?.commandNotes?.find((entry) => entry.includes("overrode user dontAsk"));
900
+ expect(overrideNote).toBeTruthy();
901
+ });
902
+ it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => {
903
+ const root = await makeTempRoot();
904
+ const cwd = path.join(root, "worktree");
905
+ await fs.mkdir(cwd, { recursive: true });
906
+ const verboseByAgent = {};
907
+ for (const agent of ["claude", "codex", "custom"]) {
908
+ const runtimeOptions = [];
909
+ const execute = createAcpxEngineExecutor({
910
+ createRuntime: (options) => {
911
+ runtimeOptions.push(options);
912
+ return buildRuntime();
913
+ },
914
+ });
915
+ const result = await execute({
916
+ runId: `run-${agent}`,
917
+ agent: { id: `agent-${agent}`, companyId: "company-1" },
918
+ runtime: {},
919
+ config: agent === "custom"
920
+ ? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd }
921
+ : { agent, stateDir: path.join(root, `state-${agent}`), cwd },
922
+ context: { paperclipWorkspace: { cwd } },
923
+ onLog: async () => { },
924
+ onMeta: async () => { },
925
+ });
926
+ expect(result.exitCode).toBe(0);
927
+ verboseByAgent[agent] = runtimeOptions[0]?.verbose;
928
+ }
929
+ expect(verboseByAgent.claude).toBe(true);
930
+ expect(verboseByAgent.codex).toBe(false);
931
+ expect(verboseByAgent.custom).toBe(false);
932
+ });
933
+ it("does not touch .claude/settings.local.json for the codex agent", async () => {
934
+ const root = await makeTempRoot();
935
+ const stateDir = path.join(root, "state");
936
+ const cwd = path.join(root, "worktree");
937
+ await fs.mkdir(cwd, { recursive: true });
938
+ await runExecutor({ agent: "codex", stateDir, cwd }, { context: { paperclipWorkspace: { cwd } } });
939
+ expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false);
940
+ });
941
+ it("changes the ACPX session fingerprint when the resolved secret manifest rotates", async () => {
942
+ const root = await makeTempRoot();
943
+ const baseConfig = {
944
+ agent: "custom",
945
+ agentCommand: "node ./fake-acp.js",
946
+ stateDir: path.join(root, "state"),
947
+ };
948
+ const first = await runExecutor(baseConfig, {
949
+ context: {
950
+ paperclipSecrets: {
951
+ manifest: [
952
+ {
953
+ configPath: "env.API_TOKEN",
954
+ envKey: "API_TOKEN",
955
+ secretId: "secret-1",
956
+ bindingId: "binding-1",
957
+ secretKey: "api-token",
958
+ version: 1,
959
+ provider: "local_encrypted",
960
+ },
961
+ ],
962
+ },
963
+ },
964
+ });
965
+ const second = await runExecutor(baseConfig, {
966
+ context: {
967
+ paperclipSecrets: {
968
+ manifest: [
969
+ {
970
+ configPath: "env.API_TOKEN",
971
+ envKey: "API_TOKEN",
972
+ secretId: "secret-1",
973
+ bindingId: "binding-1",
974
+ secretKey: "api-token",
975
+ version: 2,
976
+ provider: "local_encrypted",
977
+ },
978
+ ],
979
+ },
980
+ },
981
+ });
982
+ expect(first.result.sessionParams?.configFingerprint).toBeTypeOf("string");
983
+ expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
984
+ expect(first.result.sessionParams?.configFingerprint).not.toBe(second.result.sessionParams?.configFingerprint);
985
+ });
986
+ });
987
+ describe("findAncestorBin", () => {
988
+ async function writeFakeBin(dir, name) {
989
+ const binDir = path.join(dir, "node_modules", ".bin");
990
+ await fs.mkdir(binDir, { recursive: true });
991
+ const binPath = path.join(binDir, name);
992
+ await fs.writeFile(binPath, "#!/usr/bin/env bash\necho ok\n", { mode: 0o755 });
993
+ return binPath;
994
+ }
995
+ it("finds the binary in the start directory's own node_modules/.bin", async () => {
996
+ const root = await makeTempRoot();
997
+ const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
998
+ await fs.mkdir(packageDir, { recursive: true });
999
+ const expectedBin = await writeFakeBin(packageDir, "claude-agent-acp");
1000
+ const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
1001
+ expect(resolved).toBe(expectedBin);
1002
+ });
1003
+ it("finds the binary hoisted to an ancestor node_modules/.bin", async () => {
1004
+ const root = await makeTempRoot();
1005
+ const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
1006
+ await fs.mkdir(packageDir, { recursive: true });
1007
+ const expectedBin = await writeFakeBin(root, "claude-agent-acp");
1008
+ const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
1009
+ expect(resolved).toBe(expectedBin);
1010
+ });
1011
+ it("returns null when the binary is not present in any ancestor", async () => {
1012
+ const root = await makeTempRoot();
1013
+ const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
1014
+ await fs.mkdir(packageDir, { recursive: true });
1015
+ const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
1016
+ expect(resolved).toBeNull();
1017
+ });
1018
+ it("terminates at the filesystem root instead of looping forever", async () => {
1019
+ const resolved = await findAncestorBin("/", "definitely-not-a-real-bin-name-xyz");
1020
+ expect(resolved).toBeNull();
1021
+ });
1022
+ });
1023
+ describe("gemini ACP flag selection", () => {
1024
+ it("parses semantic version parts from gemini --version output", () => {
1025
+ expect(parseGeminiVersionParts("0.30.0")).toEqual([0, 30, 0]);
1026
+ expect(parseGeminiVersionParts("gemini-cli v1.2.3\n")).toEqual([1, 2, 3]);
1027
+ expect(parseGeminiVersionParts("no version here")).toBeNull();
1028
+ expect(parseGeminiVersionParts(null)).toBeNull();
1029
+ });
1030
+ it("keeps --acp for gemini >= 0.33.0 and unknown versions", () => {
1031
+ expect(geminiVersionSupportsNativeAcpFlag([0, 33, 0])).toBe(true);
1032
+ expect(geminiVersionSupportsNativeAcpFlag([0, 34, 1])).toBe(true);
1033
+ expect(geminiVersionSupportsNativeAcpFlag([1, 0, 0])).toBe(true);
1034
+ expect(geminiVersionSupportsNativeAcpFlag(null)).toBe(true);
1035
+ expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 33, 0])).toBe("gemini --acp");
1036
+ });
1037
+ it("downgrades --acp to --experimental-acp for gemini < 0.33.0", () => {
1038
+ expect(geminiVersionSupportsNativeAcpFlag([0, 30, 0])).toBe(false);
1039
+ expect(geminiVersionSupportsNativeAcpFlag([0, 32, 9])).toBe(false);
1040
+ expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 30, 0])).toBe("gemini --experimental-acp");
1041
+ expect(rewriteGeminiAcpFlagForVersion("/opt/bin/gemini --acp", [0, 30, 0])).toBe("/opt/bin/gemini --experimental-acp");
1042
+ });
1043
+ async function writeFakeGemini(binDir, version) {
1044
+ await fs.mkdir(binDir, { recursive: true });
1045
+ const binPath = path.join(binDir, process.platform === "win32" ? "gemini.cmd" : "gemini");
1046
+ const body = process.platform === "win32"
1047
+ ? `@echo off\r\necho ${version}\r\n`
1048
+ : `#!/bin/sh\necho "${version}"\n`;
1049
+ await fs.writeFile(binPath, body, { mode: 0o755 });
1050
+ }
1051
+ function pathWithFakeBin(binDir) {
1052
+ return [binDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
1053
+ }
1054
+ async function readGeminiWrapperScript(stateDir) {
1055
+ const wrappersDir = path.join(stateDir, "wrappers");
1056
+ const names = await fs.readdir(wrappersDir);
1057
+ const scriptName = names.find((name) => name.endsWith(".sh"));
1058
+ expect(scriptName).toBeTypeOf("string");
1059
+ return fs.readFile(path.join(wrappersDir, scriptName), "utf8");
1060
+ }
1061
+ it("writes a gemini wrapper that execs a multi-word command instead of a single quoted token", async () => {
1062
+ const root = await makeTempRoot();
1063
+ const stateDir = path.join(root, "state");
1064
+ const binDir = path.join(root, "bin");
1065
+ await writeFakeGemini(binDir, "0.33.0");
1066
+ await runExecutor({
1067
+ agent: "gemini",
1068
+ stateDir,
1069
+ env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) },
1070
+ });
1071
+ const script = await readGeminiWrapperScript(stateDir);
1072
+ expect(script).toContain('exec gemini --acp "$@"');
1073
+ expect(script).not.toContain("'gemini --acp'");
1074
+ });
1075
+ it("downgrades the built-in gemini command flag when the local CLI predates --acp", async () => {
1076
+ const root = await makeTempRoot();
1077
+ const stateDir = path.join(root, "state");
1078
+ const binDir = path.join(root, "bin");
1079
+ await writeFakeGemini(binDir, "0.30.0");
1080
+ await runExecutor({
1081
+ agent: "gemini",
1082
+ stateDir,
1083
+ env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) },
1084
+ });
1085
+ const script = await readGeminiWrapperScript(stateDir);
1086
+ expect(script).toContain('exec gemini --experimental-acp "$@"');
1087
+ });
1088
+ });
1089
+ describe("shared ACP engine execution timeouts", () => {
1090
+ it("applies the 4h sandbox backstop when timeoutSec is unset on a sandbox execution target", async () => {
1091
+ const root = await makeTempRoot();
1092
+ const stateDir = path.join(root, "state");
1093
+ const cwd = path.join(root, "worktree");
1094
+ await fs.mkdir(cwd, { recursive: true });
1095
+ const { logs, runtimeOptions } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd }, {
1096
+ executionTarget: {
1097
+ kind: "remote",
1098
+ transport: "sandbox",
1099
+ providerKey: "acme-sandbox",
1100
+ environmentId: "env-1",
1101
+ leaseId: "lease-1",
1102
+ remoteCwd: cwd,
1103
+ },
1104
+ });
1105
+ // The sandbox default flows into the ACPX runtime wall-clock timer.
1106
+ expect(runtimeOptions[0]?.timeoutMs).toBe(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000);
1107
+ // The effective timeout and its source are stated at run start so a later
1108
+ // timeout is diagnosable from the run log alone.
1109
+ const startLine = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"));
1110
+ expect(startLine).toBeTruthy();
1111
+ expect(startLine.text).toContain(`[paperclip] Adapter execution timeout: timeoutSec=${DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC} ` +
1112
+ "(sandbox default; set adapterConfig.timeoutSec to override).");
1113
+ });
1114
+ it("keeps local execution unlimited by default and logs the unlimited timeout", async () => {
1115
+ const root = await makeTempRoot();
1116
+ const stateDir = path.join(root, "state");
1117
+ const cwd = path.join(root, "worktree");
1118
+ await fs.mkdir(cwd, { recursive: true });
1119
+ const { logs, runtimeOptions } = await runExecutor({
1120
+ agent: "custom",
1121
+ agentCommand: "node ./fake-acp.js",
1122
+ stateDir,
1123
+ cwd,
1124
+ });
1125
+ expect(runtimeOptions[0]?.timeoutMs).toBeUndefined();
1126
+ const startLine = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"));
1127
+ expect(startLine).toBeTruthy();
1128
+ expect(startLine.text).toContain("Adapter execution timeout: none");
1129
+ });
1130
+ it("prefers a configured timeoutSec over the sandbox default", async () => {
1131
+ const root = await makeTempRoot();
1132
+ const stateDir = path.join(root, "state");
1133
+ const cwd = path.join(root, "worktree");
1134
+ await fs.mkdir(cwd, { recursive: true });
1135
+ const { logs, runtimeOptions } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 90 }, {
1136
+ executionTarget: {
1137
+ kind: "remote",
1138
+ transport: "sandbox",
1139
+ remoteCwd: cwd,
1140
+ },
1141
+ });
1142
+ expect(runtimeOptions[0]?.timeoutMs).toBe(90 * 1000);
1143
+ const startLine = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"));
1144
+ expect(startLine.text).toContain("Adapter execution timeout: timeoutSec=90 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).");
1145
+ });
1146
+ it("keeps the sandbox backstop for an explicit timeoutSec of 0 but honors a negative opt-out", async () => {
1147
+ const root = await makeTempRoot();
1148
+ const stateDir = path.join(root, "state");
1149
+ const cwd = path.join(root, "worktree");
1150
+ await fs.mkdir(cwd, { recursive: true });
1151
+ const sandboxContext = {
1152
+ executionTarget: {
1153
+ kind: "remote",
1154
+ transport: "sandbox",
1155
+ remoteCwd: cwd,
1156
+ },
1157
+ };
1158
+ // The config UI persists the schema default of 0 for untouched fields, so
1159
+ // an explicit 0 cannot mean "no timeout" — it keeps the 4h backstop.
1160
+ const explicitZero = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 0 }, sandboxContext);
1161
+ expect(explicitZero.runtimeOptions[0]?.timeoutMs).toBe(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000);
1162
+ // A negative timeoutSec is the documented opt-out from any adapter
1163
+ // wall-clock timeout, sandbox targets included.
1164
+ const negativeOptOut = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: -1 }, sandboxContext);
1165
+ expect(negativeOptOut.runtimeOptions[0]?.timeoutMs).toBeUndefined();
1166
+ const startLine = negativeOptOut.logs.find((entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"));
1167
+ expect(startLine.text).toContain("Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; " +
1168
+ "set it to a positive value to add one).");
1169
+ });
1170
+ it("reports a self-describing timeout error when the wall-clock timer kills a turn", async () => {
1171
+ const root = await makeTempRoot();
1172
+ const stateDir = path.join(root, "state");
1173
+ const cwd = path.join(root, "worktree");
1174
+ await fs.mkdir(cwd, { recursive: true });
1175
+ const cancelReasons = [];
1176
+ let releaseTurn = null;
1177
+ const turnCancelled = new Promise((resolve) => {
1178
+ releaseTurn = resolve;
1179
+ });
1180
+ const execute = createAcpxEngineExecutor({
1181
+ createRuntime: () => ({
1182
+ ensureSession: async () => ({
1183
+ backendSessionId: "backend-session",
1184
+ agentSessionId: "agent-session",
1185
+ runtimeSessionName: "runtime-session",
1186
+ }),
1187
+ startTurn: () => ({
1188
+ // Never yields on its own: only the Paperclip wall-clock timer's
1189
+ // cancel unblocks the turn, simulating a hung run.
1190
+ events: (async function* () {
1191
+ await turnCancelled;
1192
+ })(),
1193
+ result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
1194
+ cancel: async ({ reason }) => {
1195
+ cancelReasons.push(reason);
1196
+ releaseTurn?.();
1197
+ },
1198
+ }),
1199
+ close: async () => { },
1200
+ }),
1201
+ });
1202
+ const result = await execute({
1203
+ runId: "run-timeout-1",
1204
+ agent: { id: "agent-1", companyId: "company-1" },
1205
+ runtime: {},
1206
+ config: {
1207
+ agent: "custom",
1208
+ agentCommand: "node ./fake-acp.js",
1209
+ stateDir,
1210
+ cwd,
1211
+ timeoutSec: 1,
1212
+ },
1213
+ context: {},
1214
+ onLog: async () => { },
1215
+ onMeta: async () => { },
1216
+ });
1217
+ const expectedMessage = "Run exceeded the adapter execution timeout (timeoutSec=1, configured via adapterConfig.timeoutSec). " +
1218
+ "Set adapterConfig.timeoutSec to raise it.";
1219
+ expect(result.timedOut).toBe(true);
1220
+ expect(result.errorCode).toBe("acpx_timeout");
1221
+ expect(result.errorMessage).toBe(expectedMessage);
1222
+ expect(cancelReasons).toContain(expectedMessage);
1223
+ }, 15_000);
1224
+ });
1225
+ describe("summarizeAcpxTurnUsage", () => {
1226
+ it("uses the post-turn amount alone when the cumulative cost counter reset", () => {
1227
+ const summary = summarizeAcpxTurnUsage({
1228
+ preStatus: { usage: { cost: { amount: 2.5, currency: "USD" } } },
1229
+ postStatus: {
1230
+ usage: {
1231
+ cumulative: { inputTokens: 10, outputTokens: 20 },
1232
+ cost: { amount: 0.3, currency: "USD" },
1233
+ },
1234
+ },
1235
+ eventBreakdown: null,
1236
+ eventCostUsd: null,
1237
+ });
1238
+ expect(summary.costUsd).toBeCloseTo(0.3);
1239
+ expect(summary.cumulativeCostUsd).toBeCloseTo(0.3);
1240
+ });
1241
+ it("ignores non-USD cost amounts", () => {
1242
+ const summary = summarizeAcpxTurnUsage({
1243
+ preStatus: null,
1244
+ postStatus: { usage: { cost: { amount: 4, currency: "EUR" } } },
1245
+ eventBreakdown: null,
1246
+ eventCostUsd: null,
1247
+ });
1248
+ expect(summary.costUsd).toBeNull();
1249
+ expect(summary.cumulativeCostUsd).toBeNull();
1250
+ });
1251
+ it("returns no usage when nothing was reported", () => {
1252
+ const summary = summarizeAcpxTurnUsage({
1253
+ preStatus: null,
1254
+ postStatus: null,
1255
+ eventBreakdown: null,
1256
+ eventCostUsd: null,
1257
+ });
1258
+ expect(summary.usage).toBeNull();
1259
+ expect(summary.costUsd).toBeNull();
1260
+ });
1261
+ });
1262
+ describe("summarizeAcpxTurnUsage no-report turns", () => {
1263
+ it("suppresses usage when the turn reported nothing and the persisted breakdown is unchanged", () => {
1264
+ const stale = { inputTokens: 10, outputTokens: 500, cachedReadTokens: 30 };
1265
+ const summary = summarizeAcpxTurnUsage({
1266
+ preStatus: { usage: { cumulative: stale, cost: { amount: 0.5, currency: "USD" } } },
1267
+ postStatus: { usage: { cumulative: { ...stale }, cost: { amount: 0.5, currency: "USD" } } },
1268
+ eventBreakdown: null,
1269
+ eventCostUsd: null,
1270
+ });
1271
+ expect(summary.usage).toBeNull();
1272
+ expect(summary.usageDetail).toBeNull();
1273
+ expect(summary.costUsd).toBeCloseTo(0);
1274
+ });
1275
+ it("prefers current event usage when the persisted breakdown is stale", () => {
1276
+ const stale = { inputTokens: 10, outputTokens: 500, cachedReadTokens: 30 };
1277
+ const current = { inputTokens: 25, outputTokens: 75, cachedReadTokens: 5 };
1278
+ const summary = summarizeAcpxTurnUsage({
1279
+ preStatus: { usage: { cumulative: stale } },
1280
+ postStatus: { usage: { cumulative: { ...stale } } },
1281
+ eventBreakdown: current,
1282
+ eventCostUsd: null,
1283
+ });
1284
+ expect(summary.usage).toEqual({
1285
+ inputTokens: 25,
1286
+ outputTokens: 75,
1287
+ cachedInputTokens: 5,
1288
+ });
1289
+ expect(summary.usageDetail).toMatchObject(current);
1290
+ });
1291
+ it("treats omitted and explicit zero fields as the same stale breakdown", () => {
1292
+ const current = { inputTokens: 25, outputTokens: 75, cachedReadTokens: 5 };
1293
+ const summary = summarizeAcpxTurnUsage({
1294
+ preStatus: { usage: { cumulative: { inputTokens: 10, outputTokens: 500 } } },
1295
+ postStatus: {
1296
+ usage: {
1297
+ cumulative: {
1298
+ inputTokens: 10,
1299
+ outputTokens: 500,
1300
+ cachedReadTokens: 0,
1301
+ cachedWriteTokens: 0,
1302
+ thoughtTokens: 0,
1303
+ totalTokens: 0,
1304
+ },
1305
+ },
1306
+ },
1307
+ eventBreakdown: current,
1308
+ eventCostUsd: null,
1309
+ });
1310
+ expect(summary.usage).toEqual({
1311
+ inputTokens: 25,
1312
+ outputTokens: 75,
1313
+ cachedInputTokens: 5,
1314
+ });
1315
+ });
1316
+ it("does not reuse stale tokens when the turn reports cost only", () => {
1317
+ const stale = { inputTokens: 10, outputTokens: 500, cachedReadTokens: 30 };
1318
+ const summary = summarizeAcpxTurnUsage({
1319
+ preStatus: { usage: { cumulative: stale, cost: { amount: 0.5, currency: "USD" } } },
1320
+ postStatus: {
1321
+ usage: { cumulative: { ...stale }, cost: { amount: 0.5, currency: "USD" } },
1322
+ },
1323
+ eventBreakdown: null,
1324
+ eventCostUsd: 0.75,
1325
+ });
1326
+ expect(summary.usage).toBeNull();
1327
+ expect(summary.usageDetail).toBeNull();
1328
+ expect(summary.costUsd).toBeCloseTo(0.25);
1329
+ expect(summary.cumulativeCostUsd).toBeCloseTo(0.75);
1330
+ });
1331
+ });
1332
+ //# sourceMappingURL=execute.test.js.map