@opengeni/runtime 0.7.1 → 0.11.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 (49) hide show
  1. package/THIRD_PARTY_NOTICES +32 -0
  2. package/dist/agent-UMUIWWD2.js +8 -0
  3. package/dist/agent-UMUIWWD2.js.map +1 -0
  4. package/dist/chunk-4YJ5Z52Z.js +1789 -0
  5. package/dist/chunk-4YJ5Z52Z.js.map +1 -0
  6. package/dist/chunk-DPULLAJG.js +215 -0
  7. package/dist/chunk-DPULLAJG.js.map +1 -0
  8. package/dist/chunk-K4V4QYCL.js +37683 -0
  9. package/dist/chunk-K4V4QYCL.js.map +1 -0
  10. package/dist/chunk-PZ5AY32C.js +10 -0
  11. package/dist/chunk-PZ5AY32C.js.map +1 -0
  12. package/dist/chunk-YUAFQEFY.js +21522 -0
  13. package/dist/chunk-YUAFQEFY.js.map +1 -0
  14. package/dist/{index-DIiiwQY2.d.ts → index-D6yi-JcU.d.ts} +138 -15
  15. package/dist/index.d.ts +329 -121
  16. package/dist/index.js +8149 -307
  17. package/dist/index.js.map +1 -1
  18. package/dist/localSources-T3HZ6KY4.js +15 -0
  19. package/dist/localSources-T3HZ6KY4.js.map +1 -0
  20. package/dist/sandbox/index.d.ts +1 -1
  21. package/dist/sandbox/index.js +42 -5
  22. package/dist/skill-library.d.ts +74 -0
  23. package/dist/skill-library.js +18 -0
  24. package/dist/skill-library.js.map +1 -0
  25. package/package.json +13 -5
  26. package/src/bundled_hashicorp_terraform_skills/README.md +3 -3
  27. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +1 -1
  28. package/src/context-compaction.ts +421 -24
  29. package/src/history-sanitizer.ts +14 -5
  30. package/src/index.ts +1335 -280
  31. package/src/sandbox/channel-a.ts +535 -97
  32. package/src/sandbox/index.ts +36 -2
  33. package/src/sandbox/routing/routing-session.ts +10 -2
  34. package/src/sandbox/run-credentials.ts +818 -0
  35. package/src/sandbox/select.ts +3 -3
  36. package/src/sandbox/selfhosted/capabilities.ts +1 -1
  37. package/src/sandbox/selfhosted/op-stream.ts +40 -0
  38. package/src/sandbox/selfhosted/op-testing.ts +50 -3
  39. package/src/sandbox/selfhosted/session.ts +17 -4
  40. package/src/sandbox/selfhosted/testing.ts +1 -1
  41. package/src/sandbox/stream-port.ts +1 -1
  42. package/src/sandbox/stream-token.ts +1 -1
  43. package/src/sandbox/terminal-server.ts +6 -2
  44. package/src/sandbox/toolspace-token.ts +147 -0
  45. package/src/sandbox/turn-tool-cancellation.ts +940 -0
  46. package/src/skill-library.ts +323 -0
  47. package/dist/chunk-RYDCVTMW.js +0 -5531
  48. package/dist/chunk-RYDCVTMW.js.map +0 -1
  49. /package/src/{bundled_hashicorp_terraform_skills → bundled_skill_library}/azure-verified-modules/SKILL.md +0 -0
@@ -0,0 +1,940 @@
1
+ import type { Computer, Editor, Tool } from "@openai/agents";
2
+
3
+ import { runWithToolCallCorrelation, sanitizeOpIdToken } from "./op-correlation";
4
+ import {
5
+ isExecSessionLostBanner,
6
+ parseExecBannerExitCode,
7
+ parseExecBannerSessionId,
8
+ } from "./channel-a";
9
+
10
+ const TURN_EXEC_YIELD_MS = 250;
11
+ const TURN_WRITE_YIELD_MS = 250;
12
+ const SHELL_HELPER_YIELD_MS = 1_000;
13
+ const SHELL_GRACEFUL_POLLS = 2;
14
+ const SHELL_POLL_MS = 100;
15
+ const SHELL_MARKER_DIR = "/tmp/opengeni-turn-shell";
16
+
17
+ type FunctionTool = Extract<Tool<unknown>, { type: "function" }>;
18
+ type FunctionToolInvoke = FunctionTool["invoke"];
19
+
20
+ const delay = async (milliseconds: number): Promise<void> =>
21
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
22
+
23
+ type ShellProcessIdentity = {
24
+ pid: number;
25
+ processGroupId: number;
26
+ };
27
+
28
+ type ActiveShellSession = {
29
+ sessionId: number;
30
+ markerPath: string | null;
31
+ token: string | null;
32
+ runContext: Parameters<FunctionToolInvoke>[0];
33
+ execInvoke: FunctionToolInvoke;
34
+ writeInvoke: FunctionToolInvoke | null;
35
+ identity: ShellProcessIdentity | null;
36
+ identityValidated: boolean;
37
+ cancellation: Promise<void> | null;
38
+ };
39
+
40
+ type CommandCancellationSession = {
41
+ cancelExecCommand?(opId: string): Promise<boolean>;
42
+ supportsPty?(): boolean;
43
+ };
44
+
45
+ export type TurnSandboxCommandArgs = {
46
+ cmd: string;
47
+ workdir?: string;
48
+ shell?: string;
49
+ login?: boolean;
50
+ tty?: boolean;
51
+ yieldTimeMs?: number;
52
+ maxOutputTokens?: number;
53
+ runAs?: string;
54
+ };
55
+
56
+ export type TurnSandboxCommandSession = CommandCancellationSession & {
57
+ exec?(args: TurnSandboxCommandArgs): Promise<unknown>;
58
+ execCommand?(args: TurnSandboxCommandArgs): Promise<string>;
59
+ writeStdin?(args: {
60
+ sessionId: number;
61
+ chars?: string;
62
+ yieldTimeMs?: number;
63
+ maxOutputTokens?: number;
64
+ }): Promise<string>;
65
+ };
66
+
67
+ type ActiveRemoteExec = {
68
+ settled: boolean;
69
+ settledPromise: Promise<void>;
70
+ settle(): void;
71
+ cancel(): Promise<void>;
72
+ };
73
+
74
+ /**
75
+ * The worker may publish an attempt-quiesced receipt only after this fence
76
+ * resolves. `cancel()` prevents new turn-owned sandbox calls, actively stops
77
+ * shell processes, and `waitForQuiescence()` drains every capability call that
78
+ * was already in flight. Cleanup/telemetry outside the sandbox capability set
79
+ * remains independently attempt-fenced.
80
+ */
81
+ export type TurnToolCancellationFence = {
82
+ cancel(reason?: unknown): void;
83
+ waitForQuiescence(): Promise<void>;
84
+ runSandboxCommand(
85
+ session: TurnSandboxCommandSession,
86
+ args: TurnSandboxCommandArgs,
87
+ ): Promise<unknown>;
88
+ };
89
+
90
+ export type TurnToolCancellationController = TurnToolCancellationFence & {
91
+ wrapTools(tools: Tool<unknown>[], session?: CommandCancellationSession): Tool<unknown>[];
92
+ };
93
+
94
+ export class TurnSandboxCommandCancelledError extends Error {
95
+ readonly name = "TurnSandboxCommandCancelledError";
96
+
97
+ constructor(reason: unknown) {
98
+ super(
99
+ reason instanceof Error
100
+ ? reason.message
101
+ : String(reason ?? "Turn sandbox tools were cancelled"),
102
+ {
103
+ ...(reason instanceof Error ? { cause: reason } : {}),
104
+ },
105
+ );
106
+ }
107
+ }
108
+
109
+ function cancellationError(reason: unknown): Error {
110
+ return new TurnSandboxCommandCancelledError(reason);
111
+ }
112
+
113
+ function parsedObject(input: string): Record<string, unknown> | null {
114
+ try {
115
+ const parsed = JSON.parse(input) as unknown;
116
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
117
+ ? (parsed as Record<string, unknown>)
118
+ : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function cappedYield(value: unknown, cap: number): number {
125
+ return typeof value === "number" && Number.isInteger(value) && value >= 0
126
+ ? Math.min(value, cap)
127
+ : cap;
128
+ }
129
+
130
+ function execOutput(raw: string): string {
131
+ const marker = "\nOutput:\n";
132
+ const index = raw.indexOf(marker);
133
+ return index >= 0 ? raw.slice(index + marker.length) : "";
134
+ }
135
+
136
+ function nativeCommandBanner(result: unknown): string {
137
+ if (typeof result === "string") return result;
138
+ if (!result || typeof result !== "object") {
139
+ throw new Error("Sandbox command returned an invalid result");
140
+ }
141
+ const value = result as {
142
+ output?: unknown;
143
+ stdout?: unknown;
144
+ stderr?: unknown;
145
+ exitCode?: unknown;
146
+ exit_code?: unknown;
147
+ sessionId?: unknown;
148
+ session_id?: unknown;
149
+ };
150
+ const sessionId =
151
+ typeof value.sessionId === "number"
152
+ ? value.sessionId
153
+ : typeof value.session_id === "number"
154
+ ? value.session_id
155
+ : null;
156
+ const exitCode =
157
+ typeof value.exitCode === "number"
158
+ ? value.exitCode
159
+ : typeof value.exit_code === "number"
160
+ ? value.exit_code
161
+ : null;
162
+ const output = [value.output, value.stderr, value.stdout]
163
+ .filter(
164
+ (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
165
+ )
166
+ .join("\n");
167
+ if (sessionId !== null) {
168
+ return `Process running with session ID ${sessionId}\n\nOutput:\n${output}`;
169
+ }
170
+ if (exitCode !== null) {
171
+ return `Process exited with code ${exitCode}\n\nOutput:\n${output}`;
172
+ }
173
+ throw new Error("Sandbox command did not report a session id or exit code");
174
+ }
175
+
176
+ function completedCommandBanner(exitCode: number, output: string): string {
177
+ return `Process exited with code ${exitCode}\n\nOutput:\n${output}`;
178
+ }
179
+
180
+ function appendBoundedOutput(current: string, chunk: string, maxOutputTokens: number): string {
181
+ const maxChars = Math.max(512, maxOutputTokens * 4);
182
+ const combined = current + chunk;
183
+ return combined.length <= maxChars ? combined : combined.slice(-maxChars);
184
+ }
185
+
186
+ function singleQuote(value: string): string {
187
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
188
+ }
189
+
190
+ function shellMarkerPath(token: string): string {
191
+ return `${SHELL_MARKER_DIR}/${token}`;
192
+ }
193
+
194
+ function cancellableGroupLeaderCommand(command: string, markerPath: string): string {
195
+ const marker = singleQuote(markerPath);
196
+ const markerDir = singleQuote(SHELL_MARKER_DIR);
197
+ return [
198
+ `__opengeni_marker=${marker}`,
199
+ "umask 077",
200
+ `command mkdir -p ${markerDir} || exit 125`,
201
+ '__opengeni_pid="$$"',
202
+ '__opengeni_pgid="$(command ps -o pgid= -p "$__opengeni_pid" 2>/dev/null | command tr -d \'[:space:]\')"',
203
+ 'case "$__opengeni_pid:$__opengeni_pgid" in *[!0-9:]*|*:|:*) exit 125 ;; esac',
204
+ '[ "$__opengeni_pid" -gt 1 ] && [ "$__opengeni_pgid" -gt 1 ] || exit 125',
205
+ // Never signal a provider/container-wide process group. A cancellable PTY
206
+ // command must be its group's leader; fail the command closed otherwise.
207
+ '[ "$__opengeni_pid" = "$__opengeni_pgid" ] || exit 125',
208
+ 'command printf \'%s %s\\n\' "$__opengeni_pid" "$__opengeni_pgid" > "$__opengeni_marker" || exit 125',
209
+ "trap 'command rm -f \"$__opengeni_marker\"' EXIT",
210
+ "(",
211
+ command,
212
+ ")",
213
+ "__opengeni_status=$?",
214
+ 'exit "$__opengeni_status"',
215
+ ].join("\n");
216
+ }
217
+
218
+ export function cancellableShellCommand(command: string, markerPath: string): string {
219
+ const groupLeaderCommand = cancellableGroupLeaderCommand(command, markerPath);
220
+ return [
221
+ '__opengeni_outer_pid="$$"',
222
+ '__opengeni_outer_pgid="$(command ps -o pgid= -p "$__opengeni_outer_pid" 2>/dev/null | command tr -d \'[:space:]\')"',
223
+ 'case "$__opengeni_outer_pid:$__opengeni_outer_pgid" in *[!0-9:]*|*:|:*) exit 125 ;; esac',
224
+ 'if [ "$__opengeni_outer_pid" != "$__opengeni_outer_pgid" ]; then',
225
+ ' __opengeni_setsid="$(command -v setsid 2>/dev/null)"',
226
+ ' [ -n "$__opengeni_setsid" ] || exit 125',
227
+ ` exec "$__opengeni_setsid" /bin/sh -c ${singleQuote(groupLeaderCommand)}`,
228
+ "fi",
229
+ groupLeaderCommand,
230
+ ].join("\n");
231
+ }
232
+
233
+ function shellHelperInput(command: string): string {
234
+ return JSON.stringify({
235
+ cmd: command,
236
+ login: false,
237
+ tty: false,
238
+ yield_time_ms: SHELL_HELPER_YIELD_MS,
239
+ max_output_tokens: 128,
240
+ });
241
+ }
242
+
243
+ function safeProcessIdentity(raw: string): ShellProcessIdentity | null {
244
+ if (parseExecBannerExitCode(raw) !== 0) return null;
245
+ const match = execOutput(raw)
246
+ .trim()
247
+ .match(/^(\d+)\s+(\d+)$/);
248
+ if (!match) return null;
249
+ const pid = Number.parseInt(match[1]!, 10);
250
+ const processGroupId = Number.parseInt(match[2]!, 10);
251
+ return Number.isSafeInteger(pid) &&
252
+ Number.isSafeInteger(processGroupId) &&
253
+ pid > 1 &&
254
+ processGroupId > 1
255
+ ? { pid, processGroupId }
256
+ : null;
257
+ }
258
+
259
+ function identityGuardScript(
260
+ state: ActiveShellSession,
261
+ body: string,
262
+ missingIdentityExitCode: number,
263
+ ): string {
264
+ const identity = state.identity;
265
+ if (!identity || !state.token) return `exit ${missingIdentityExitCode}`;
266
+ const token = singleQuote(state.token);
267
+ return [
268
+ `__opengeni_pid=${identity.pid}`,
269
+ `__opengeni_pgid=${identity.processGroupId}`,
270
+ `__opengeni_token=${token}`,
271
+ // The randomized token lands late in the wrapped command line. Procps
272
+ // otherwise truncates `args` to the terminal width, which can make a live
273
+ // group look stale and open the quiescence fence without signalling it.
274
+ '__opengeni_args="$(command ps -ww -o args= -p "$__opengeni_pid" 2>/dev/null)"',
275
+ 'case "$__opengeni_args" in *"$__opengeni_token"*) ;; *) exit 0 ;; esac',
276
+ '__opengeni_live_pgid="$(command ps -o pgid= -p "$__opengeni_pid" 2>/dev/null | command tr -d \'[:space:]\')"',
277
+ '[ "$__opengeni_live_pgid" = "$__opengeni_pgid" ] || exit 0',
278
+ body,
279
+ ].join("\n");
280
+ }
281
+
282
+ function wrapComputer(computer: Computer, track: <T>(operation: () => Promise<T>) => Promise<T>) {
283
+ const wrapped = Object.create(Object.getPrototypeOf(computer)) as Computer &
284
+ Record<string, unknown>;
285
+ Object.assign(wrapped, computer);
286
+ for (const method of [
287
+ "initRun",
288
+ "screenshot",
289
+ "click",
290
+ "doubleClick",
291
+ "scroll",
292
+ "type",
293
+ "wait",
294
+ "move",
295
+ "keypress",
296
+ "drag",
297
+ ] as const) {
298
+ const original = (computer as unknown as Record<string, unknown>)[method];
299
+ if (typeof original !== "function") continue;
300
+ wrapped[method] = (...args: unknown[]) =>
301
+ track(async () => await original.apply(computer, args)) as never;
302
+ }
303
+ return wrapped;
304
+ }
305
+
306
+ class TurnToolCancellationControllerImpl implements TurnToolCancellationController {
307
+ private cancelled = false;
308
+ private reason: unknown;
309
+ private readonly inFlight = new Set<Promise<unknown>>();
310
+ private readonly shellSessions = new Map<number, ActiveShellSession>();
311
+ private readonly remoteExecs = new Set<ActiveRemoteExec>();
312
+ private rawExecInvoke: FunctionToolInvoke | null = null;
313
+ private rawWriteInvoke: FunctionToolInvoke | null = null;
314
+ private drainPromise: Promise<void> | null = null;
315
+ private readonly signal: AbortSignal | undefined;
316
+ private readonly onAbort = (): void => this.cancel(this.signal?.reason);
317
+
318
+ constructor(signal?: AbortSignal) {
319
+ this.signal = signal;
320
+ if (signal?.aborted) {
321
+ this.cancel(signal.reason);
322
+ } else {
323
+ signal?.addEventListener("abort", this.onAbort, { once: true });
324
+ }
325
+ }
326
+
327
+ cancel(reason?: unknown): void {
328
+ if (!this.cancelled) {
329
+ this.cancelled = true;
330
+ this.reason = reason;
331
+ this.signal?.removeEventListener("abort", this.onAbort);
332
+ }
333
+ void this.ensureDrain().catch(() => undefined);
334
+ }
335
+
336
+ async waitForQuiescence(): Promise<void> {
337
+ this.cancel(this.reason);
338
+ await this.ensureDrain();
339
+ }
340
+
341
+ runSandboxCommand(
342
+ session: TurnSandboxCommandSession,
343
+ args: TurnSandboxCommandArgs,
344
+ ): Promise<unknown> {
345
+ return this.track(async () => {
346
+ if (!session.exec && !session.execCommand) {
347
+ throw new Error("Sandbox session does not support command execution");
348
+ }
349
+ const lifecycleRunContext = {} as Parameters<FunctionToolInvoke>[0];
350
+
351
+ const invokeExec: FunctionToolInvoke = async (_runContext, input) => {
352
+ const parsed = parsedObject(input);
353
+ if (!parsed || typeof parsed.cmd !== "string") {
354
+ throw new Error("Sandbox lifecycle command input was invalid");
355
+ }
356
+ const commandArgs: TurnSandboxCommandArgs = {
357
+ cmd: parsed.cmd,
358
+ ...(typeof parsed.workdir === "string" ? { workdir: parsed.workdir } : {}),
359
+ ...(typeof parsed.shell === "string" ? { shell: parsed.shell } : {}),
360
+ ...(typeof parsed.login === "boolean" ? { login: parsed.login } : {}),
361
+ ...(typeof parsed.tty === "boolean" ? { tty: parsed.tty } : {}),
362
+ ...(typeof parsed.yield_time_ms === "number"
363
+ ? { yieldTimeMs: parsed.yield_time_ms }
364
+ : {}),
365
+ ...(typeof parsed.max_output_tokens === "number"
366
+ ? { maxOutputTokens: parsed.max_output_tokens }
367
+ : {}),
368
+ ...(typeof parsed.run_as === "string" ? { runAs: parsed.run_as } : {}),
369
+ };
370
+ // Provider PTY/session semantics live on execCommand (the same surface
371
+ // used by the SDK shell tool). Some providers expose a lower-level exec
372
+ // that waits for natural completion even when tty/yield are supplied;
373
+ // choosing it here would put lifecycle setup back outside the <=2s
374
+ // Steer/Pause boundary. Connected machines advertise no PTY and use the
375
+ // structured exec path so their durable OpCancel correlation is retained.
376
+ const result =
377
+ session.supportsPty?.() !== false && session.execCommand
378
+ ? await session.execCommand(commandArgs)
379
+ : session.exec
380
+ ? await session.exec(commandArgs)
381
+ : await session.execCommand!(commandArgs);
382
+ return nativeCommandBanner(result);
383
+ };
384
+ const invokeWrite: FunctionToolInvoke | null = session.writeStdin
385
+ ? async (_runContext, input) => {
386
+ const parsed = parsedObject(input);
387
+ if (!parsed || typeof parsed.session_id !== "number") {
388
+ throw new Error("Sandbox lifecycle stdin input was invalid");
389
+ }
390
+ return await session.writeStdin!({
391
+ sessionId: parsed.session_id,
392
+ ...(typeof parsed.chars === "string" ? { chars: parsed.chars } : {}),
393
+ ...(typeof parsed.yield_time_ms === "number"
394
+ ? { yieldTimeMs: parsed.yield_time_ms }
395
+ : {}),
396
+ ...(typeof parsed.max_output_tokens === "number"
397
+ ? { maxOutputTokens: parsed.max_output_tokens }
398
+ : {}),
399
+ });
400
+ }
401
+ : null;
402
+ const correlationId = `turn_lifecycle_${crypto.randomUUID()}`;
403
+ const useRemoteOpCancellation =
404
+ Boolean(session.cancelExecCommand) && session.supportsPty?.() === false;
405
+ const remoteExec =
406
+ session.cancelExecCommand && useRemoteOpCancellation
407
+ ? this.registerRemoteExec(
408
+ { cancelExecCommand: session.cancelExecCommand.bind(session) },
409
+ `${sanitizeOpIdToken(correlationId)}:0`,
410
+ )
411
+ : null;
412
+ const markerPath = shellMarkerPath(crypto.randomUUID());
413
+ const commandInput = JSON.stringify({
414
+ cmd: useRemoteOpCancellation ? args.cmd : cancellableShellCommand(args.cmd, markerPath),
415
+ ...(args.workdir ? { workdir: args.workdir } : {}),
416
+ ...(args.shell ? { shell: args.shell } : {}),
417
+ ...(args.login !== undefined ? { login: args.login } : {}),
418
+ ...(args.runAs ? { run_as: args.runAs } : {}),
419
+ tty: useRemoteOpCancellation ? args.tty : true,
420
+ yield_time_ms: useRemoteOpCancellation
421
+ ? args.yieldTimeMs
422
+ : cappedYield(args.yieldTimeMs, TURN_EXEC_YIELD_MS),
423
+ ...(args.maxOutputTokens !== undefined ? { max_output_tokens: args.maxOutputTokens } : {}),
424
+ });
425
+
426
+ let initial: Awaited<ReturnType<FunctionToolInvoke>>;
427
+ try {
428
+ initial = await runWithToolCallCorrelation(
429
+ correlationId,
430
+ async () => await invokeExec(lifecycleRunContext, commandInput, undefined),
431
+ );
432
+ } finally {
433
+ remoteExec?.settle();
434
+ }
435
+ if (typeof initial !== "string" || useRemoteOpCancellation) return initial;
436
+
437
+ const sessionId = parseExecBannerSessionId(initial);
438
+ if (sessionId === null) return initial;
439
+ if (!invokeWrite) {
440
+ throw new Error("Sandbox lifecycle command yielded without stdin support");
441
+ }
442
+ const token = markerPath.slice(markerPath.lastIndexOf("/") + 1);
443
+ const state: ActiveShellSession = {
444
+ sessionId,
445
+ markerPath,
446
+ token,
447
+ runContext: lifecycleRunContext,
448
+ execInvoke: invokeExec,
449
+ writeInvoke: invokeWrite,
450
+ identity: null,
451
+ identityValidated: false,
452
+ cancellation: null,
453
+ };
454
+ this.shellSessions.set(sessionId, state);
455
+ const maxOutputTokens = args.maxOutputTokens ?? 20_000;
456
+ const initialOutput = execOutput(initial);
457
+ let output = initialOutput ? appendBoundedOutput("", initialOutput, maxOutputTokens) : "";
458
+ while (true) {
459
+ if (this.cancelled) throw cancellationError(this.reason);
460
+ const next = await invokeWrite(
461
+ lifecycleRunContext,
462
+ JSON.stringify({
463
+ session_id: sessionId,
464
+ chars: "",
465
+ yield_time_ms: TURN_WRITE_YIELD_MS,
466
+ max_output_tokens: maxOutputTokens,
467
+ }),
468
+ undefined,
469
+ );
470
+ if (typeof next !== "string") {
471
+ throw new Error("Sandbox lifecycle stdin returned an invalid result");
472
+ }
473
+ const nextOutput = execOutput(next);
474
+ if (nextOutput) output = appendBoundedOutput(output, nextOutput, maxOutputTokens);
475
+ const exitCode = parseExecBannerExitCode(next);
476
+ if (exitCode !== null) {
477
+ this.shellSessions.delete(sessionId);
478
+ return completedCommandBanner(exitCode, output);
479
+ }
480
+ // A conforming provider blocks for the requested yield. This fallback
481
+ // prevents a non-conforming implementation from turning a long setup
482
+ // command into a hot control-plane polling loop.
483
+ await delay(SHELL_POLL_MS);
484
+ }
485
+ });
486
+ }
487
+
488
+ wrapTools(tools: Tool<unknown>[], session?: CommandCancellationSession): Tool<unknown>[] {
489
+ for (const tool of tools) {
490
+ if (tool.type !== "function") continue;
491
+ if (tool.name === "exec_command") this.rawExecInvoke = tool.invoke;
492
+ if (tool.name === "write_stdin") this.rawWriteInvoke = tool.invoke;
493
+ }
494
+
495
+ return tools.map((tool) => {
496
+ if (tool.type === "function") return this.wrapFunctionTool(tool, session);
497
+ if (tool.type === "apply_patch") return this.wrapApplyPatchTool(tool);
498
+ if (tool.type === "computer") return this.wrapComputerTool(tool);
499
+ return tool;
500
+ });
501
+ }
502
+
503
+ private wrapFunctionTool(
504
+ tool: FunctionTool,
505
+ cancellationSession?: CommandCancellationSession,
506
+ ): FunctionTool {
507
+ if (tool.name === "exec_command") {
508
+ return {
509
+ ...tool,
510
+ invoke: (runContext, input, details) =>
511
+ this.track(async () => {
512
+ const parsed = parsedObject(input);
513
+ if (!parsed || typeof parsed.cmd !== "string") {
514
+ return await this.correlatedInvoke(tool.invoke, runContext, input, details);
515
+ }
516
+ const token = crypto.randomUUID();
517
+ const markerPath = shellMarkerPath(token);
518
+ const correlationId = details?.toolCall?.callId ?? `turn_exec_${token}`;
519
+ const cancelExecCommand = cancellationSession?.cancelExecCommand;
520
+ // Connected-machine exec is already a durable process-tree op and
521
+ // may run on Windows/macOS/Linux. Its OpCancel needs no POSIX shell
522
+ // wrapper and preserves the user's command byte-for-byte. PTY-backed
523
+ // cloud/local sessions use the portable SDK session + POSIX marker.
524
+ const useRemoteOpCancellation =
525
+ Boolean(cancelExecCommand) && cancellationSession?.supportsPty?.() === false;
526
+ const remoteExec =
527
+ cancelExecCommand && useRemoteOpCancellation
528
+ ? this.registerRemoteExec(
529
+ { cancelExecCommand: cancelExecCommand.bind(cancellationSession) },
530
+ `${sanitizeOpIdToken(correlationId)}:0`,
531
+ )
532
+ : null;
533
+ const cancellableInput = useRemoteOpCancellation
534
+ ? input
535
+ : JSON.stringify({
536
+ ...parsed,
537
+ cmd: cancellableShellCommand(parsed.cmd, markerPath),
538
+ // The SDK's only provider-neutral live-process interrupt is a PTY.
539
+ // The process marker provides TERM/KILL escalation when Ctrl-C is
540
+ // ignored; the short yield exposes the provider session promptly.
541
+ tty: true,
542
+ yield_time_ms: cappedYield(parsed.yield_time_ms, TURN_EXEC_YIELD_MS),
543
+ });
544
+ let output: Awaited<ReturnType<FunctionToolInvoke>>;
545
+ try {
546
+ output = await runWithToolCallCorrelation(
547
+ correlationId,
548
+ async () => await tool.invoke(runContext, cancellableInput, details),
549
+ );
550
+ } finally {
551
+ remoteExec?.settle();
552
+ }
553
+ if (typeof output !== "string") return output;
554
+ const sessionId = useRemoteOpCancellation ? null : parseExecBannerSessionId(output);
555
+ if (sessionId !== null) {
556
+ this.shellSessions.set(sessionId, {
557
+ sessionId,
558
+ markerPath,
559
+ token,
560
+ runContext,
561
+ execInvoke: tool.invoke,
562
+ writeInvoke: this.rawWriteInvoke,
563
+ identity: null,
564
+ identityValidated: false,
565
+ cancellation: null,
566
+ });
567
+ }
568
+ return output;
569
+ }),
570
+ };
571
+ }
572
+
573
+ if (tool.name === "write_stdin") {
574
+ return {
575
+ ...tool,
576
+ invoke: (runContext, input, details) =>
577
+ this.track(async () => {
578
+ const parsed = parsedObject(input);
579
+ const sessionId =
580
+ parsed &&
581
+ typeof parsed.session_id === "number" &&
582
+ Number.isSafeInteger(parsed.session_id)
583
+ ? parsed.session_id
584
+ : null;
585
+ const cappedInput = parsed
586
+ ? JSON.stringify({
587
+ ...parsed,
588
+ yield_time_ms: cappedYield(parsed.yield_time_ms, TURN_WRITE_YIELD_MS),
589
+ })
590
+ : input;
591
+ const output = await tool.invoke(runContext, cappedInput, details);
592
+ if (sessionId !== null && typeof output === "string") {
593
+ if (isExecSessionLostBanner(output, sessionId)) {
594
+ // Modal reports a vanished exec session as a non-throwing
595
+ // string. The matching provider banner is positive evidence
596
+ // that this exact tracked PTY no longer exists; a different id
597
+ // or an ambiguous error must leave the registration fenced.
598
+ this.shellSessions.delete(sessionId);
599
+ } else if (parseExecBannerSessionId(output) === sessionId) {
600
+ if (!this.shellSessions.has(sessionId)) {
601
+ this.shellSessions.set(sessionId, {
602
+ sessionId,
603
+ markerPath: null,
604
+ token: null,
605
+ runContext,
606
+ execInvoke: this.rawExecInvoke ?? tool.invoke,
607
+ writeInvoke: tool.invoke,
608
+ identity: null,
609
+ identityValidated: false,
610
+ cancellation: null,
611
+ });
612
+ }
613
+ } else if (parseExecBannerExitCode(output) !== null) {
614
+ this.shellSessions.delete(sessionId);
615
+ }
616
+ }
617
+ return output;
618
+ }),
619
+ };
620
+ }
621
+
622
+ return {
623
+ ...tool,
624
+ invoke: (runContext, input, details) =>
625
+ this.track(async () => await tool.invoke(runContext, input, details)),
626
+ };
627
+ }
628
+
629
+ private wrapApplyPatchTool(tool: Extract<Tool<unknown>, { type: "apply_patch" }>) {
630
+ const editor = tool.editor;
631
+ const wrappedEditor: Editor = {
632
+ createFile: (operation, context) =>
633
+ this.track(async () => await editor.createFile(operation, context)),
634
+ updateFile: (operation, context) =>
635
+ this.track(async () => await editor.updateFile(operation, context)),
636
+ deleteFile: (operation, context) =>
637
+ this.track(async () => await editor.deleteFile(operation, context)),
638
+ };
639
+ return { ...tool, editor: wrappedEditor };
640
+ }
641
+
642
+ private wrapComputerTool(tool: Extract<Tool<unknown>, { type: "computer" }>) {
643
+ const computer = tool.computer;
644
+ if (typeof computer === "function") {
645
+ return {
646
+ ...tool,
647
+ computer: async (...args: Parameters<typeof computer>) =>
648
+ wrapComputer(await this.track(async () => await computer(...args)), (operation) =>
649
+ this.track(operation),
650
+ ),
651
+ };
652
+ }
653
+ if ("create" in computer && typeof computer.create === "function") {
654
+ return {
655
+ ...tool,
656
+ computer: {
657
+ ...computer,
658
+ create: async (...args: Parameters<typeof computer.create>) =>
659
+ wrapComputer(
660
+ await this.track(async () => await computer.create(...args)),
661
+ (operation) => this.track(operation),
662
+ ),
663
+ },
664
+ };
665
+ }
666
+ return {
667
+ ...tool,
668
+ computer: wrapComputer(computer as Computer, (operation) => this.track(operation)),
669
+ };
670
+ }
671
+
672
+ private async correlatedInvoke(
673
+ invoke: FunctionToolInvoke,
674
+ runContext: Parameters<FunctionToolInvoke>[0],
675
+ input: string,
676
+ details: Parameters<FunctionToolInvoke>[2],
677
+ ) {
678
+ const callId = details?.toolCall?.callId;
679
+ return callId
680
+ ? await runWithToolCallCorrelation(
681
+ callId,
682
+ async () => await invoke(runContext, input, details),
683
+ )
684
+ : await invoke(runContext, input, details);
685
+ }
686
+
687
+ private track<T>(operation: () => Promise<T>): Promise<T> {
688
+ if (this.cancelled) return Promise.reject(cancellationError(this.reason));
689
+ const promise = Promise.resolve().then(async () => {
690
+ if (this.cancelled) throw cancellationError(this.reason);
691
+ return await operation();
692
+ });
693
+ this.inFlight.add(promise);
694
+ void promise
695
+ .finally(() => {
696
+ this.inFlight.delete(promise);
697
+ })
698
+ .catch(() => undefined);
699
+ return promise;
700
+ }
701
+
702
+ private ensureDrain(): Promise<void> {
703
+ this.drainPromise ??= this.drain();
704
+ return this.drainPromise;
705
+ }
706
+
707
+ private async drain(): Promise<void> {
708
+ while (true) {
709
+ // Op-stream commands (connected/self-hosted machines) do not yield a PTY
710
+ // session id. Issue their idempotent OpCancel first so the in-flight tool
711
+ // promise can physically settle; cancellation-before-start is tombstoned
712
+ // by the runner and therefore cannot race into a late process spawn.
713
+ const remoteCancellations = [...this.remoteExecs].map(async (exec) => await exec.cancel());
714
+ const inFlight = [...this.inFlight];
715
+ if (inFlight.length > 0 || remoteCancellations.length > 0) {
716
+ await Promise.allSettled([...inFlight, ...remoteCancellations]);
717
+ }
718
+
719
+ const sessions = [...this.shellSessions.values()];
720
+ if (sessions.length > 0) {
721
+ await Promise.all(sessions.map(async (session) => await this.cancelShellSession(session)));
722
+ }
723
+
724
+ if (this.inFlight.size === 0 && this.shellSessions.size === 0 && this.remoteExecs.size === 0)
725
+ return;
726
+ }
727
+ }
728
+
729
+ private registerRemoteExec(
730
+ session: Required<Pick<CommandCancellationSession, "cancelExecCommand">>,
731
+ opId: string,
732
+ ): ActiveRemoteExec {
733
+ let resolveSettled!: () => void;
734
+ const settledPromise = new Promise<void>((resolve) => {
735
+ resolveSettled = resolve;
736
+ });
737
+ let cancellation: Promise<void> | null = null;
738
+ const entry: ActiveRemoteExec = {
739
+ settled: false,
740
+ settledPromise,
741
+ settle: () => {
742
+ if (entry.settled) return;
743
+ entry.settled = true;
744
+ resolveSettled();
745
+ this.remoteExecs.delete(entry);
746
+ },
747
+ cancel: () => {
748
+ cancellation ??= (async () => {
749
+ while (!entry.settled) {
750
+ const attempt = session.cancelExecCommand(opId).catch(() => false);
751
+ // A routing/provider cancellation request can itself hang while the
752
+ // ordinary PTY exec has already yielded. Do not let that advisory
753
+ // route hold the authoritative local/session fence; its late result
754
+ // is idempotent and already rejection-contained.
755
+ await Promise.race([attempt, entry.settledPromise]);
756
+ await Promise.race([entry.settledPromise, delay(SHELL_POLL_MS)]);
757
+ }
758
+ })();
759
+ return cancellation;
760
+ },
761
+ };
762
+ this.remoteExecs.add(entry);
763
+ return entry;
764
+ }
765
+
766
+ private cancelShellSession(state: ActiveShellSession): Promise<void> {
767
+ state.cancellation ??= this.cancelShellSessionOnce(state).finally(() => {
768
+ state.cancellation = null;
769
+ });
770
+ return state.cancellation;
771
+ }
772
+
773
+ private async cancelShellSessionOnce(state: ActiveShellSession): Promise<void> {
774
+ // A session inherited from an older/unwrapped invocation has no durable
775
+ // process marker. Ctrl-C + provider completion is the only safe generic
776
+ // authority available for that legacy shape.
777
+ if (!state.markerPath) {
778
+ while (true) {
779
+ if (state.writeInvoke && (await this.rawWrite(state, "\u0003", SHELL_POLL_MS))) {
780
+ this.shellSessions.delete(state.sessionId);
781
+ return;
782
+ }
783
+ await delay(SHELL_POLL_MS);
784
+ }
785
+ }
786
+
787
+ // Capture PID/PGID BEFORE Ctrl-C. A foreground child may ignore INT/TERM
788
+ // while its supervising shell exits; provider-session completion alone is
789
+ // therefore not proof that the whole process group stopped.
790
+ while (!state.identity) {
791
+ state.identity = await this.readIdentity(state);
792
+ if (state.identity) break;
793
+ if (state.writeInvoke) {
794
+ const completed = await this.rawWrite(state, "", SHELL_POLL_MS);
795
+ if (completed) {
796
+ this.shellSessions.delete(state.sessionId);
797
+ return;
798
+ }
799
+ }
800
+ await delay(SHELL_POLL_MS);
801
+ }
802
+
803
+ if (!(await this.identityAlive(state))) {
804
+ this.shellSessions.delete(state.sessionId);
805
+ return;
806
+ }
807
+
808
+ if (state.writeInvoke) {
809
+ await this.rawWrite(state, "\u0003", SHELL_POLL_MS);
810
+ if (!(await this.identityAlive(state))) {
811
+ this.shellSessions.delete(state.sessionId);
812
+ return;
813
+ }
814
+ }
815
+
816
+ await this.signalIdentity(state, "TERM");
817
+ for (let attempt = 0; attempt < SHELL_GRACEFUL_POLLS; attempt++) {
818
+ if (state.writeInvoke) await this.rawWrite(state, "", SHELL_POLL_MS);
819
+ if (!(await this.identityAlive(state))) {
820
+ this.shellSessions.delete(state.sessionId);
821
+ return;
822
+ }
823
+ }
824
+
825
+ await this.signalIdentity(state, "KILL");
826
+ while (await this.identityAlive(state)) {
827
+ if (state.writeInvoke) await this.rawWrite(state, "", SHELL_POLL_MS);
828
+ // A transient helper failure must not turn the first KILL into a single
829
+ // best-effort shot. Re-issue it until the process group is provably gone.
830
+ await this.signalIdentity(state, "KILL");
831
+ await delay(SHELL_POLL_MS);
832
+ }
833
+ this.shellSessions.delete(state.sessionId);
834
+ }
835
+
836
+ private async rawWrite(
837
+ state: ActiveShellSession,
838
+ chars: string,
839
+ yieldTimeMs: number,
840
+ ): Promise<boolean> {
841
+ if (!state.writeInvoke) return false;
842
+ try {
843
+ const output = await state.writeInvoke(
844
+ state.runContext,
845
+ JSON.stringify({
846
+ session_id: state.sessionId,
847
+ chars,
848
+ yield_time_ms: yieldTimeMs,
849
+ max_output_tokens: 128,
850
+ }),
851
+ undefined,
852
+ );
853
+ if (typeof output !== "string") return false;
854
+ // Marker files can already be gone when a process exited outside the
855
+ // model-facing write path. Modal's exact matching lost-session banner is
856
+ // then the only positive provider proof that this tracked PTY is absent.
857
+ if (isExecSessionLostBanner(output, state.sessionId)) return true;
858
+ if (parseExecBannerSessionId(output) === state.sessionId) return false;
859
+ if (parseExecBannerExitCode(output) !== null) return true;
860
+ } catch {
861
+ // A transient provider/control-plane failure is not evidence that the
862
+ // remote process stopped. The identity probe/escalation remains the
863
+ // authority and retries until it can prove quiescence.
864
+ }
865
+ return false;
866
+ }
867
+
868
+ private async readIdentity(state: ActiveShellSession): Promise<ShellProcessIdentity | null> {
869
+ if (!state.markerPath) return null;
870
+ try {
871
+ const output = await state.execInvoke(
872
+ state.runContext,
873
+ shellHelperInput(`command cat ${singleQuote(state.markerPath)} 2>/dev/null`),
874
+ undefined,
875
+ );
876
+ return typeof output === "string" ? safeProcessIdentity(output) : null;
877
+ } catch {
878
+ return null;
879
+ }
880
+ }
881
+
882
+ private async identityAlive(state: ActiveShellSession): Promise<boolean> {
883
+ const script = state.identityValidated
884
+ ? [
885
+ `__opengeni_pgid=${state.identity?.processGroupId ?? 0}`,
886
+ 'command kill -0 "-$__opengeni_pgid" 2>/dev/null && exit 75',
887
+ "exit 0",
888
+ ].join("\n")
889
+ : identityGuardScript(
890
+ state,
891
+ 'command kill -0 "-$__opengeni_pgid" 2>/dev/null && exit 75\nexit 0',
892
+ 76,
893
+ );
894
+ try {
895
+ const output = await state.execInvoke(state.runContext, shellHelperInput(script), undefined);
896
+ if (typeof output !== "string") return true;
897
+ const exitCode = parseExecBannerExitCode(output);
898
+ if (exitCode === 75) {
899
+ state.identityValidated = true;
900
+ return true;
901
+ }
902
+ return exitCode !== 0;
903
+ } catch {
904
+ return true;
905
+ }
906
+ }
907
+
908
+ private async signalIdentity(state: ActiveShellSession, signal: "TERM" | "KILL"): Promise<void> {
909
+ const script = state.identityValidated
910
+ ? `command kill -${signal} "-${state.identity?.processGroupId ?? 0}" 2>/dev/null || exit 76`
911
+ : identityGuardScript(
912
+ state,
913
+ `command kill -${signal} "-$__opengeni_pgid" 2>/dev/null || exit 76\nexit 0`,
914
+ 76,
915
+ );
916
+ try {
917
+ await state.execInvoke(state.runContext, shellHelperInput(script), undefined);
918
+ } catch {
919
+ // Confirmation is performed by identityAlive(). A failed signal helper
920
+ // never opens the fence; it is retried/escalated until the process is gone.
921
+ }
922
+ }
923
+ }
924
+
925
+ export function createTurnToolCancellationController(
926
+ signal?: AbortSignal,
927
+ ): TurnToolCancellationController {
928
+ return new TurnToolCancellationControllerImpl(signal);
929
+ }
930
+
931
+ /** Wrap a capability instance without depending on SDK-private subclasses. */
932
+ export function wrapCapabilityToolsForTurnCancellation(
933
+ capability: { tools(): Tool<unknown>[]; _session?: CommandCancellationSession },
934
+ controller: TurnToolCancellationController,
935
+ ): void {
936
+ const originalTools = capability.tools;
937
+ capability.tools = function () {
938
+ return controller.wrapTools(originalTools.call(this), this._session);
939
+ };
940
+ }