@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
@@ -1,13 +1,20 @@
1
1
  import { createServer } from "node:http";
2
+ import net from "node:net";
3
+ import { execFile, spawn } from "node:child_process";
2
4
  import { existsSync } from "node:fs";
3
- import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
5
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
6
  import os from "node:os";
5
7
  import path from "node:path";
8
+ import { promisify } from "node:util";
6
9
  import { afterEach, describe, expect, it, vi } from "vitest";
7
- import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, adapterExecutionTargetUsesPaperclipBridge, ensureAdapterExecutionTargetCommandResolvable, resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, } from "./execution-target.js";
10
+ import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, adapterExecutionTargetUsesPaperclipBridge, ensureAdapterExecutionTargetCommandResolvable, formatAdapterExecutionTimeoutErrorMessage, formatAdapterExecutionTimeoutStartLogLine, resolveAdapterExecutionTargetTimeout, resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetProcessSessionBridge, startAdapterExecutionTargetPaperclipBridge, } from "./execution-target.js";
8
11
  import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js";
9
12
  import { runChildProcess } from "./server-utils.js";
10
13
  import { shellQuote } from "./ssh.js";
14
+ const execFileAsync = promisify(execFile);
15
+ const PROCESS_SESSION_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 5_000;
16
+ const PROCESS_SESSION_PROXY_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 5_000;
17
+ const PROCESS_SESSION_LIVE_OUTPUT_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 3_000;
11
18
  function resolveTestPosixShellCommand(command) {
12
19
  if (process.platform !== "win32")
13
20
  return command === "bash" ? "/bin/bash" : "sh";
@@ -107,8 +114,8 @@ describe("sandbox adapter execution targets", () => {
107
114
  "",
108
115
  ].join("\n");
109
116
  }
110
- async function waitForCondition(predicate, message) {
111
- const deadline = Date.now() + 1000;
117
+ async function waitForCondition(predicate, message, timeoutMs = 1000) {
118
+ const deadline = Date.now() + timeoutMs;
112
119
  while (Date.now() < deadline) {
113
120
  if (predicate())
114
121
  return;
@@ -116,6 +123,60 @@ describe("sandbox adapter execution targets", () => {
116
123
  }
117
124
  throw new Error(message);
118
125
  }
126
+ function parseProcessSessionProxyCommand(command) {
127
+ if (process.platform !== "win32")
128
+ return { command, args: [], sourcePath: command };
129
+ const match = command.match(/^"([^"]+)"\s+"([^"]+)"$/);
130
+ if (!match)
131
+ throw new Error(`Unexpected Windows process session proxy command: ${command}`);
132
+ return { command: match[1], args: [match[2]], sourcePath: match[2] };
133
+ }
134
+ function spawnProcessSessionProxy(agentCommand) {
135
+ const parsed = parseProcessSessionProxyCommand(agentCommand);
136
+ return spawn(parsed.command, parsed.args, {
137
+ stdio: ["pipe", "pipe", "pipe"],
138
+ });
139
+ }
140
+ function processSessionProxySourcePath(command) {
141
+ return parseProcessSessionProxyCommand(command).sourcePath;
142
+ }
143
+ async function waitForChildSpawn(child) {
144
+ await new Promise((resolve, reject) => {
145
+ child.once("spawn", resolve);
146
+ child.once("error", reject);
147
+ });
148
+ }
149
+ async function runProxyWithInput(bridge, input) {
150
+ const child = spawnProcessSessionProxy(bridge.agentCommand);
151
+ let stdout = "";
152
+ let stderr = "";
153
+ child.stdout.setEncoding("utf8");
154
+ child.stderr.setEncoding("utf8");
155
+ child.stdout.on("data", (chunk) => {
156
+ stdout += chunk;
157
+ });
158
+ child.stderr.on("data", (chunk) => {
159
+ stderr += chunk;
160
+ });
161
+ await waitForChildSpawn(child);
162
+ await bridge.ready;
163
+ child.stdin.end(input);
164
+ const code = await new Promise((resolve, reject) => {
165
+ const timeout = setTimeout(() => {
166
+ child.kill("SIGKILL");
167
+ reject(new Error(`Timed out waiting for process session proxy (stdout=${JSON.stringify(stdout)}, stderr=${JSON.stringify(stderr)}).`));
168
+ }, PROCESS_SESSION_PROXY_TIMEOUT_MS);
169
+ child.on("error", (error) => {
170
+ clearTimeout(timeout);
171
+ reject(error);
172
+ });
173
+ child.on("close", (exitCode) => {
174
+ clearTimeout(timeout);
175
+ resolve(exitCode);
176
+ });
177
+ });
178
+ return { stdout, stderr, code };
179
+ }
119
180
  function combinedStream(events, stream) {
120
181
  return events.filter((event) => event.stream === stream).map((event) => event.chunk).join("");
121
182
  }
@@ -167,6 +228,268 @@ describe("sandbox adapter execution targets", () => {
167
228
  remoteCwd: "/workspace",
168
229
  });
169
230
  });
231
+ it("bridges bidirectional sandbox process sessions through a local ACPX-spawnable proxy", async () => {
232
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-"));
233
+ cleanupDirs.push(rootDir);
234
+ const childPath = path.join(rootDir, "fake-acp-child.mjs");
235
+ await writeFile(childPath, [
236
+ "process.stdin.on('data', (chunk) => {",
237
+ " process.stdout.write('out:' + chunk.toString());",
238
+ " process.stderr.write('err:' + chunk.toString());",
239
+ "});",
240
+ ].join("\n"), "utf8");
241
+ const target = {
242
+ kind: "remote",
243
+ transport: "sandbox",
244
+ providerKey: "local-test",
245
+ remoteCwd: rootDir,
246
+ timeoutMs: 30_000,
247
+ runner: createLocalSandboxRunner(),
248
+ };
249
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
250
+ runId: "run-process-session",
251
+ target,
252
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
253
+ adapterKey: "acpx",
254
+ command: process.execPath,
255
+ args: [childPath],
256
+ cwd: rootDir,
257
+ env: {},
258
+ timeoutSec: 5,
259
+ onLog: async () => { },
260
+ });
261
+ expect(bridge).not.toBeNull();
262
+ try {
263
+ const result = await runProxyWithInput(bridge, "hello\n");
264
+ expect(result.code).toBe(0);
265
+ expect(result.stdout).toBe("out:hello\n");
266
+ expect(result.stderr).toBe("err:hello\n");
267
+ }
268
+ finally {
269
+ await bridge?.stop();
270
+ }
271
+ }, PROCESS_SESSION_TEST_TIMEOUT_MS);
272
+ it("buffers sandbox process session output until the local proxy connects", async () => {
273
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-"));
274
+ cleanupDirs.push(rootDir);
275
+ const childPath = path.join(rootDir, "fast-acp-child.mjs");
276
+ await writeFile(childPath, [
277
+ "process.stdout.write('early-out\\n');",
278
+ "process.stderr.write('early-err\\n');",
279
+ "setTimeout(() => process.exit(0), 20);",
280
+ ].join("\n"), "utf8");
281
+ const target = {
282
+ kind: "remote",
283
+ transport: "sandbox",
284
+ providerKey: "local-test",
285
+ remoteCwd: rootDir,
286
+ timeoutMs: 30_000,
287
+ runner: createLocalSandboxRunner(),
288
+ };
289
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
290
+ runId: "run-process-session-buffer",
291
+ target,
292
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
293
+ adapterKey: "acpx",
294
+ command: process.execPath,
295
+ args: [childPath],
296
+ cwd: rootDir,
297
+ env: {},
298
+ timeoutSec: 5,
299
+ onLog: async () => { },
300
+ });
301
+ expect(bridge).not.toBeNull();
302
+ try {
303
+ await new Promise((resolve) => setTimeout(resolve, 300));
304
+ const result = await runProxyWithInput(bridge, "");
305
+ expect(result.code).toBe(0);
306
+ expect(result.stdout).toBe("early-out\n");
307
+ expect(result.stderr).toBe("early-err\n");
308
+ }
309
+ finally {
310
+ await bridge?.stop();
311
+ }
312
+ }, PROCESS_SESSION_TEST_TIMEOUT_MS);
313
+ it("delivers full output when the sandbox child exits immediately after writing", async () => {
314
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-fast-exit-"));
315
+ cleanupDirs.push(rootDir);
316
+ const childPath = path.join(rootDir, "instant-exit-acp-child.mjs");
317
+ await writeFile(childPath, [
318
+ "process.stdout.write('final-out\\n');",
319
+ "process.stderr.write('final-err\\n');",
320
+ ].join("\n"), "utf8");
321
+ const target = {
322
+ kind: "remote",
323
+ transport: "sandbox",
324
+ providerKey: "local-test",
325
+ remoteCwd: rootDir,
326
+ timeoutMs: 30_000,
327
+ runner: createLocalSandboxRunner(),
328
+ };
329
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
330
+ runId: "run-process-session-fast-exit",
331
+ target,
332
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
333
+ adapterKey: "acpx",
334
+ command: process.execPath,
335
+ args: [childPath],
336
+ cwd: rootDir,
337
+ env: {},
338
+ timeoutSec: 5,
339
+ onLog: async () => { },
340
+ });
341
+ expect(bridge).not.toBeNull();
342
+ try {
343
+ const result = await runProxyWithInput(bridge, "");
344
+ expect(result.code).toBe(0);
345
+ expect(result.stdout).toBe("final-out\n");
346
+ expect(result.stderr).toBe("final-err\n");
347
+ }
348
+ finally {
349
+ await bridge?.stop();
350
+ }
351
+ }, PROCESS_SESSION_TEST_TIMEOUT_MS);
352
+ it("ignores unauthenticated connections to the process session bridge", async () => {
353
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-auth-"));
354
+ cleanupDirs.push(rootDir);
355
+ const childPath = path.join(rootDir, "guarded-acp-child.mjs");
356
+ await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8");
357
+ const target = {
358
+ kind: "remote",
359
+ transport: "sandbox",
360
+ providerKey: "local-test",
361
+ remoteCwd: rootDir,
362
+ timeoutMs: 30_000,
363
+ runner: createLocalSandboxRunner(),
364
+ };
365
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
366
+ runId: "run-process-session-auth",
367
+ target,
368
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
369
+ adapterKey: "acpx",
370
+ command: process.execPath,
371
+ args: [childPath],
372
+ cwd: rootDir,
373
+ env: {},
374
+ timeoutSec: 5,
375
+ onLog: async () => { },
376
+ });
377
+ expect(bridge).not.toBeNull();
378
+ let squatter = null;
379
+ try {
380
+ const proxySource = await readFile(processSessionProxySourcePath(bridge.agentCommand), "utf8");
381
+ const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
382
+ expect(Number.isFinite(port)).toBe(true);
383
+ // An idle local connection must not claim the session or see buffered output.
384
+ const squatterSocket = net.createConnection({ host: "127.0.0.1", port });
385
+ squatter = squatterSocket;
386
+ let squatterReceived = "";
387
+ squatterSocket.setEncoding("utf8");
388
+ squatterSocket.on("data", (chunk) => {
389
+ squatterReceived += chunk;
390
+ });
391
+ squatterSocket.on("error", () => undefined);
392
+ await new Promise((resolve, reject) => {
393
+ squatterSocket.once("connect", () => resolve());
394
+ squatterSocket.once("error", reject);
395
+ });
396
+ // A peer presenting the wrong token is disconnected outright.
397
+ const badPeer = net.createConnection({ host: "127.0.0.1", port });
398
+ badPeer.on("error", () => undefined);
399
+ const badPeerClosed = new Promise((resolve) => badPeer.once("close", () => resolve()));
400
+ badPeer.once("connect", () => badPeer.write(`${JSON.stringify({ token: "wrong-token", type: "stdinEnd" })}\n`));
401
+ await badPeerClosed;
402
+ // The authenticated proxy still attaches and receives the buffered output.
403
+ const result = await runProxyWithInput(bridge, "");
404
+ expect(result.code).toBe(0);
405
+ expect(result.stdout).toBe("guarded-out\n");
406
+ expect(squatterReceived).toBe("");
407
+ }
408
+ finally {
409
+ squatter?.destroy();
410
+ await bridge?.stop();
411
+ }
412
+ }, PROCESS_SESSION_TEST_TIMEOUT_MS);
413
+ it("streams sandbox process session output before the remote child exits", async () => {
414
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-"));
415
+ cleanupDirs.push(rootDir);
416
+ const childPath = path.join(rootDir, "streaming-acp-child.mjs");
417
+ await writeFile(childPath, [
418
+ "process.stdin.setEncoding('utf8');",
419
+ "process.stdin.on('data', (chunk) => {",
420
+ " if (chunk.includes('ping')) {",
421
+ " process.stdout.write('delta:ping\\n');",
422
+ " process.stderr.write('trace:ping\\n');",
423
+ " }",
424
+ " if (chunk.includes('finish')) process.exit(0);",
425
+ "});",
426
+ "process.stdin.resume();",
427
+ ].join("\n"), "utf8");
428
+ const target = {
429
+ kind: "remote",
430
+ transport: "sandbox",
431
+ providerKey: "local-test",
432
+ remoteCwd: rootDir,
433
+ timeoutMs: 30_000,
434
+ runner: createLocalSandboxRunner(),
435
+ };
436
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
437
+ runId: "run-process-session-stream",
438
+ target,
439
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
440
+ adapterKey: "acpx",
441
+ command: process.execPath,
442
+ args: [childPath],
443
+ cwd: rootDir,
444
+ env: {},
445
+ timeoutSec: 5,
446
+ onLog: async () => { },
447
+ });
448
+ expect(bridge).not.toBeNull();
449
+ const child = spawnProcessSessionProxy(bridge.agentCommand);
450
+ let stdout = "";
451
+ let stderr = "";
452
+ let exited = false;
453
+ child.stdout.setEncoding("utf8");
454
+ child.stderr.setEncoding("utf8");
455
+ child.stdout.on("data", (chunk) => {
456
+ stdout += chunk;
457
+ });
458
+ child.stderr.on("data", (chunk) => {
459
+ stderr += chunk;
460
+ });
461
+ const exitPromise = new Promise((resolve, reject) => {
462
+ const timeout = setTimeout(() => {
463
+ child.kill("SIGKILL");
464
+ reject(new Error("Timed out waiting for streaming process session proxy."));
465
+ }, PROCESS_SESSION_PROXY_TIMEOUT_MS);
466
+ child.on("error", (error) => {
467
+ clearTimeout(timeout);
468
+ reject(error);
469
+ });
470
+ child.on("exit", (exitCode) => {
471
+ exited = true;
472
+ clearTimeout(timeout);
473
+ resolve(exitCode);
474
+ });
475
+ });
476
+ try {
477
+ await waitForChildSpawn(child);
478
+ await bridge.ready;
479
+ child.stdin.write("ping\n");
480
+ await waitForCondition(() => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"), "Timed out waiting for live process session output.", PROCESS_SESSION_LIVE_OUTPUT_TIMEOUT_MS);
481
+ expect(exited).toBe(false);
482
+ child.stdin.end("finish\n");
483
+ await expect(exitPromise).resolves.toBe(0);
484
+ }
485
+ finally {
486
+ if (!exited) {
487
+ child.kill("SIGKILL");
488
+ await exitPromise.catch(() => undefined);
489
+ }
490
+ await bridge?.stop();
491
+ }
492
+ }, PROCESS_SESSION_TEST_TIMEOUT_MS);
170
493
  it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
171
494
  const sandboxTarget = {
172
495
  kind: "remote",
@@ -174,6 +497,10 @@ describe("sandbox adapter execution targets", () => {
174
497
  remoteCwd: "/workspace",
175
498
  runner: createLocalSandboxRunner(),
176
499
  };
500
+ // The sandbox default is a 4h wall-clock backstop matching the recovery
501
+ // watchdog critical threshold (ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS);
502
+ // the output-inactivity monitor remains the primary hang detector.
503
+ expect(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC).toBe(4 * 60 * 60);
177
504
  expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC);
178
505
  expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 90)).toBe(90);
179
506
  expect(resolveAdapterExecutionTargetTimeoutSec({
@@ -191,6 +518,90 @@ describe("sandbox adapter execution targets", () => {
191
518
  strictHostKeyChecking: true,
192
519
  },
193
520
  }, 0)).toBe(0);
521
+ expect(resolveAdapterExecutionTargetTimeoutSec({ kind: "local" }, 0)).toBe(0);
522
+ });
523
+ it("reports which knob produced the resolved timeout", () => {
524
+ const sandboxTarget = {
525
+ kind: "remote",
526
+ transport: "sandbox",
527
+ remoteCwd: "/workspace",
528
+ runner: createLocalSandboxRunner(),
529
+ };
530
+ expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({
531
+ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
532
+ source: "sandbox_default",
533
+ });
534
+ expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 90)).toEqual({
535
+ timeoutSec: 90,
536
+ source: "configured",
537
+ });
538
+ expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0)).toEqual({
539
+ timeoutSec: 0,
540
+ source: "unlimited",
541
+ });
542
+ // Fractional (sub-second) configured timeouts are preserved rather than
543
+ // floored to 0, which would silently mean "no timeout".
544
+ expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, 0.01)).toEqual({
545
+ timeoutSec: 0.01,
546
+ source: "configured",
547
+ });
548
+ expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0.5)).toEqual({
549
+ timeoutSec: 0.5,
550
+ source: "configured",
551
+ });
552
+ });
553
+ it("treats a negative timeoutSec as the explicit no-timeout opt-out, even on sandbox targets", () => {
554
+ const sandboxTarget = {
555
+ kind: "remote",
556
+ transport: "sandbox",
557
+ remoteCwd: "/workspace",
558
+ runner: createLocalSandboxRunner(),
559
+ };
560
+ expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, -1)).toEqual({
561
+ timeoutSec: 0,
562
+ source: "configured",
563
+ });
564
+ expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, -1)).toEqual({
565
+ timeoutSec: 0,
566
+ source: "configured",
567
+ });
568
+ expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, -1)).toBe(0);
569
+ // Explicit zero intentionally does NOT opt out: the adapter config UI
570
+ // persists the schema default of 0 for untouched fields, so a stored
571
+ // timeoutSec=0 cannot be read as operator intent. It keeps the sandbox
572
+ // backstop; the documented opt-out is a negative value.
573
+ expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, 0)).toEqual({
574
+ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
575
+ source: "sandbox_default",
576
+ });
577
+ // Unset behaves like zero.
578
+ expect(resolveAdapterExecutionTargetTimeout(sandboxTarget, undefined)).toEqual({
579
+ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
580
+ source: "sandbox_default",
581
+ });
582
+ expect(resolveAdapterExecutionTargetTimeout({ kind: "local" }, undefined)).toEqual({
583
+ timeoutSec: 0,
584
+ source: "unlimited",
585
+ });
586
+ });
587
+ it("formats self-describing timeout errors naming the timer and knob", () => {
588
+ expect(formatAdapterExecutionTimeoutErrorMessage({
589
+ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
590
+ source: "sandbox_default",
591
+ })).toBe("Run exceeded the adapter execution timeout (timeoutSec=14400, sandbox default). " +
592
+ "Set adapterConfig.timeoutSec to raise it.");
593
+ expect(formatAdapterExecutionTimeoutErrorMessage({ timeoutSec: 1800, source: "configured" })).toBe("Run exceeded the adapter execution timeout (timeoutSec=1800, configured via adapterConfig.timeoutSec). " +
594
+ "Set adapterConfig.timeoutSec to raise it.");
595
+ });
596
+ it("formats the start-of-run timeout log line with the resolved value and source", () => {
597
+ expect(formatAdapterExecutionTimeoutStartLogLine({
598
+ timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
599
+ source: "sandbox_default",
600
+ })).toBe("Adapter execution timeout: timeoutSec=14400 (sandbox default; set adapterConfig.timeoutSec to override).");
601
+ expect(formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 900, source: "configured" })).toBe("Adapter execution timeout: timeoutSec=900 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).");
602
+ expect(formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "unlimited" })).toBe("Adapter execution timeout: none (no adapter wall-clock timeout for this target; set adapterConfig.timeoutSec to add one).");
603
+ // Negative opt-out resolves to { timeoutSec: 0, source: "configured" }.
604
+ expect(formatAdapterExecutionTimeoutStartLogLine({ timeoutSec: 0, source: "configured" })).toBe("Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; set it to a positive value to add one).");
194
605
  });
195
606
  it("uses the caller timeout override when installing a missing sandbox command", async () => {
196
607
  const runner = {
@@ -820,7 +1231,7 @@ describe("sandbox adapter execution targets", () => {
820
1231
  try {
821
1232
  expect(bridge).not.toBeNull();
822
1233
  expect(runner.execute).toHaveBeenCalled();
823
- expect(runner.execute.mock.calls.some(([input]) => input.timeoutMs === 1_800_000)).toBe(true);
1234
+ expect(runner.execute.mock.calls.some(([input]) => input.timeoutMs === DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000)).toBe(true);
824
1235
  }
825
1236
  finally {
826
1237
  await bridge?.stop();