@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.
- package/README.md +1 -1
- package/dist/acpx-engine/cli.d.ts +2 -0
- package/dist/acpx-engine/cli.d.ts.map +1 -0
- package/dist/acpx-engine/cli.js +128 -0
- package/dist/acpx-engine/cli.js.map +1 -0
- package/dist/acpx-engine/constants.d.ts +16 -0
- package/dist/acpx-engine/constants.d.ts.map +1 -0
- package/dist/acpx-engine/constants.js +18 -0
- package/dist/acpx-engine/constants.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +57 -0
- package/dist/acpx-engine/execute.d.ts.map +1 -0
- package/dist/acpx-engine/execute.js +1849 -0
- package/dist/acpx-engine/execute.js.map +1 -0
- package/dist/acpx-engine/execute.test.d.ts +2 -0
- package/dist/acpx-engine/execute.test.d.ts.map +1 -0
- package/dist/acpx-engine/execute.test.js +1332 -0
- package/dist/acpx-engine/execute.test.js.map +1 -0
- package/dist/acpx-engine/index.d.ts +6 -0
- package/dist/acpx-engine/index.d.ts.map +1 -0
- package/dist/acpx-engine/index.js +6 -0
- package/dist/acpx-engine/index.js.map +1 -0
- package/dist/acpx-engine/session-codec.d.ts +3 -0
- package/dist/acpx-engine/session-codec.d.ts.map +1 -0
- package/dist/acpx-engine/session-codec.js +48 -0
- package/dist/acpx-engine/session-codec.js.map +1 -0
- package/dist/acpx-engine/ui.d.ts +3 -0
- package/dist/acpx-engine/ui.d.ts.map +1 -0
- package/dist/acpx-engine/ui.js +162 -0
- package/dist/acpx-engine/ui.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +416 -5
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target.d.ts +37 -1
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +488 -9
- package/dist/execution-target.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +9 -4
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +4 -0
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/server-utils.d.ts +18 -0
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +85 -19
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +256 -8
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +0 -6
- package/dist/session-compaction.js.map +1 -1
- package/dist/types.d.ts +35 -1
- package/dist/types.d.ts.map +1 -1
- 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() +
|
|
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 ===
|
|
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();
|