@mono-agent/agent-runtime 0.15.3 → 0.15.4

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 (36) hide show
  1. package/README.md +43 -6
  2. package/package.json +5 -1
  3. package/src/agent/tools/agent-tool.js +859 -0
  4. package/src/agent/tools/bash.js +241 -123
  5. package/src/agent/tools/exec.js +238 -0
  6. package/src/agent/tools/index.js +10 -3
  7. package/src/agent/tools/node-repl.js +231 -95
  8. package/src/agent/tools/pi-bridge.js +115 -24
  9. package/src/agent/tools/shared/process-runner.js +162 -0
  10. package/src/agent/tools/shared/semaphore.js +73 -0
  11. package/src/agent/tools/web-browser-render.js +221 -0
  12. package/src/agent/tools/web-controller.js +160 -0
  13. package/src/agent/tools/web-fetch.js +653 -68
  14. package/src/agent/tools/web-search.js +568 -16
  15. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  16. package/src/ai/providers/pi-native/turn-runner.js +60 -5
  17. package/src/ai/providers/pi-native.js +49 -5
  18. package/src/ai/runtime/router.js +302 -166
  19. package/src/ai/types.js +52 -1
  20. package/src/runtime.js +51 -1
  21. package/types/agent/tools/agent-tool.d.ts +60 -0
  22. package/types/agent/tools/bash.d.ts +55 -7
  23. package/types/agent/tools/exec.d.ts +53 -0
  24. package/types/agent/tools/index.d.ts +5 -3
  25. package/types/agent/tools/node-repl.d.ts +28 -3
  26. package/types/agent/tools/pi-bridge.d.ts +6 -2
  27. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  28. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  29. package/types/agent/tools/web-browser-render.d.ts +16 -0
  30. package/types/agent/tools/web-controller.d.ts +20 -0
  31. package/types/agent/tools/web-fetch.d.ts +74 -5
  32. package/types/agent/tools/web-search.d.ts +81 -5
  33. package/types/ai/providers/pi-native/turn-runner.d.ts +34 -2
  34. package/types/ai/providers/pi-native.d.ts +12 -0
  35. package/types/ai/runtime/router.d.ts +23 -3
  36. package/types/ai/types.d.ts +163 -1
@@ -1,4 +1,5 @@
1
- import { spawn } from "node:child_process";
1
+ // @ts-check
2
+
2
3
  import { existsSync } from "node:fs";
3
4
  import { passthroughSandbox } from "../sandbox-seam.js";
4
5
  import { DEFAULT_MAX_BASH_OUTPUT_CHARS } from "./shared/constants.js";
@@ -8,157 +9,274 @@ import {
8
9
  isWorkdirAllowed,
9
10
  workspaceRoot,
10
11
  } from "./shared/path-resolver.js";
12
+ import {
13
+ combinedProcessOutput,
14
+ DEFAULT_PROCESS_BUFFER_BYTES,
15
+ runPreparedProcess,
16
+ } from "./shared/process-runner.js";
11
17
  import { readToolRuntime } from "./shared/runtime-context.js";
12
18
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
13
19
 
14
- const DEFAULT_BASH_TIMEOUT_MS = 120000;
15
- const BASH_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
16
- const KILL_GRACE_MS = 1000;
20
+ const DEFAULT_BASH_TIMEOUT_MS = 120_000;
21
+ const BASH_STARTUP_ENV_KEYS = new Set([
22
+ "BASHOPTS",
23
+ "BASH_COMPAT",
24
+ "BASH_XTRACEFD",
25
+ "CDPATH",
26
+ "GLOBIGNORE",
27
+ "POSIXLY_CORRECT",
28
+ "PROMPT_COMMAND",
29
+ "PS4",
30
+ "SHELLOPTS",
31
+ ]);
17
32
 
33
+ /**
34
+ * Legacy Bash timeout normalization. Values up to 600 are seconds; larger
35
+ * values are milliseconds. New callers should use `timeout_ms`.
36
+ */
18
37
  export function normalizeBashTimeoutMs(value, fallback = DEFAULT_BASH_TIMEOUT_MS) {
19
- const cap = Number.isFinite(Number(fallback)) && Number(fallback) > 0
20
- ? Math.floor(Number(fallback))
21
- : DEFAULT_BASH_TIMEOUT_MS;
38
+ const cap = finitePositiveInteger(fallback, DEFAULT_BASH_TIMEOUT_MS);
22
39
  const n = Number(value);
23
40
  if (!Number.isFinite(n) || n <= 0) return cap;
24
41
  const floored = Math.floor(n);
25
- const ms = floored <= 600 ? floored * 1000 : floored;
26
- return Math.max(1000, Math.min(ms, cap));
42
+ const milliseconds = floored <= 600 ? floored * 1_000 : floored;
43
+ return Math.max(1_000, Math.min(milliseconds, cap));
27
44
  }
28
45
 
29
- function killProcessGroup(child, signal) {
30
- if (!child?.pid) return;
31
- try {
32
- process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
33
- } catch {
34
- try { process.kill(child.pid, signal); } catch { /* already gone */ }
35
- }
36
- }
37
-
38
- function appendChunk(chunks, chunk, state) {
39
- state.bytes += chunk.length;
40
- if (state.bytes > state.maxBufferBytes) {
41
- state.bufferExceeded = true;
42
- return false;
43
- }
44
- chunks.push(chunk);
45
- return true;
46
+ /**
47
+ * Exact millisecond timeout used by Bash.timeout_ms and Exec.timeout_ms.
48
+ */
49
+ export function normalizeProcessTimeoutMs(value, fallback = DEFAULT_BASH_TIMEOUT_MS) {
50
+ const cap = finitePositiveInteger(fallback, DEFAULT_BASH_TIMEOUT_MS);
51
+ const n = Number(value);
52
+ if (!Number.isFinite(n) || n <= 0) return cap;
53
+ return Math.max(1, Math.min(Math.floor(n), cap));
46
54
  }
47
55
 
48
- function runCommand(commandSpec, { timeoutMs, signal, maxBufferBytes = BASH_MAX_BUFFER_BYTES }) {
49
- return new Promise((resolve) => {
50
- const child = spawn(commandSpec.command, commandSpec.args || [], {
51
- cwd: commandSpec.cwd,
52
- detached: true,
53
- env: commandSpec.env ? { ...process.env, ...commandSpec.env } : process.env,
54
- stdio: ["ignore", "pipe", "pipe"],
55
- });
56
- const stdout = [];
57
- const stderr = [];
58
- const state = {
59
- aborted: false,
60
- bufferExceeded: false,
61
- bytes: 0,
62
- maxBufferBytes,
63
- spawnError: null,
64
- timedOut: false,
65
- };
66
- let killTimer = null;
67
- let settled = false;
68
-
69
- function terminate() {
70
- killProcessGroup(child, "SIGTERM");
71
- if (!killTimer) {
72
- killTimer = setTimeout(() => killProcessGroup(child, "SIGKILL"), KILL_GRACE_MS);
73
- killTimer.unref?.();
74
- }
75
- }
76
-
77
- const timeoutTimer = setTimeout(() => {
78
- state.timedOut = true;
79
- terminate();
80
- }, timeoutMs);
81
- timeoutTimer.unref?.();
82
-
83
- const onAbort = () => {
84
- state.aborted = true;
85
- terminate();
86
- };
87
- if (signal?.aborted) onAbort();
88
- else signal?.addEventListener?.("abort", onAbort, { once: true });
89
-
90
- child.stdout?.on("data", (chunk) => {
91
- if (!appendChunk(stdout, chunk, state)) terminate();
92
- });
93
- child.stderr?.on("data", (chunk) => {
94
- if (!appendChunk(stderr, chunk, state)) terminate();
95
- });
96
- child.once("error", (err) => {
97
- state.spawnError = err;
98
- });
99
- child.once("close", (code, closeSignal) => {
100
- if (settled) return;
101
- settled = true;
102
- clearTimeout(timeoutTimer);
103
- if (killTimer) clearTimeout(killTimer);
104
- signal?.removeEventListener?.("abort", onAbort);
105
- resolve({
106
- code,
107
- signal: closeSignal,
108
- stdout: Buffer.concat(stdout).toString("utf8"),
109
- stderr: Buffer.concat(stderr).toString("utf8"),
110
- ...state,
111
- });
112
- });
113
- });
56
+ /**
57
+ * Compatibility wrapper retained for direct callers and tests.
58
+ *
59
+ * @param {{command: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string}} params
60
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
61
+ */
62
+ export async function bashToolImpl(params, options = {}) {
63
+ return (await bashToolRun(params, options)).text;
114
64
  }
115
65
 
116
66
  /**
117
- * @param {{command: string, timeout?: number, max_output_chars?: number, workdir?: string}} params
118
- * @param {{signal?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
67
+ * Structured Bash execution used by the Pi bridge.
68
+ *
69
+ * @param {{command: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string}} params
70
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
119
71
  */
120
- export async function bashToolImpl({ command, timeout = DEFAULT_BASH_TIMEOUT_MS, max_output_chars, workdir }, { signal, sandboxPolicy, sandboxEngine, ctx } = {}) {
72
+ export async function bashToolRun(
73
+ {
74
+ command,
75
+ timeout,
76
+ timeout_ms,
77
+ max_output_chars,
78
+ workdir,
79
+ },
80
+ {
81
+ signal,
82
+ sandboxPolicy,
83
+ sandboxEngine,
84
+ ctx,
85
+ } = {},
86
+ ) {
87
+ const startedAt = Date.now();
88
+ if (typeof command !== "string") {
89
+ return failed("Error: Bash command must be a string.", "invalid_command", startedAt);
90
+ }
91
+ if (command.includes("\0")) {
92
+ return failed("Error: Bash command must not contain NUL characters.", "invalid_command", startedAt);
93
+ }
121
94
  const resolvedCtx = ctx ?? readToolRuntime();
122
95
  const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
123
96
  const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
124
- const pathOptions = { sandboxPolicy: policy, ctx };
125
- if (workdir && !isWorkdirAllowed(workdir, pathOptions)) return `Error: Working directory not allowed: ${workdir}`;
126
- const cwd = workspaceRoot(workdir, ctx);
127
- if (!isPathAllowed(cwd, workdir, pathOptions)) return `Error: Working directory not allowed: ${cwd}`;
128
- if (!existsSync(cwd)) return `Error: Working directory not found: ${cwd}`;
129
- const maxChars = Number(max_output_chars) || DEFAULT_MAX_BASH_OUTPUT_CHARS;
130
- const timeoutMs = normalizeBashTimeoutMs(timeout);
97
+ const pathOptions = { sandboxPolicy: policy, ctx: resolvedCtx };
98
+ if (workdir && !isWorkdirAllowed(workdir, pathOptions)) {
99
+ return failed(`Error: Working directory not allowed: ${workdir}`, "workdir_denied", startedAt);
100
+ }
101
+ const cwd = workspaceRoot(workdir, resolvedCtx);
102
+ if (!isPathAllowed(cwd, workdir, pathOptions)) {
103
+ return failed(`Error: Working directory not allowed: ${cwd}`, "workdir_denied", startedAt);
104
+ }
105
+ if (!existsSync(cwd)) {
106
+ return failed(`Error: Working directory not found: ${cwd}`, "workdir_not_found", startedAt);
107
+ }
108
+
109
+ const maxChars = finitePositiveInteger(max_output_chars, DEFAULT_MAX_BASH_OUTPUT_CHARS);
110
+ const legacyTimeoutUsed = timeout_ms === undefined && timeout !== undefined;
111
+ const timeoutMs = timeout_ms === undefined
112
+ ? normalizeBashTimeoutMs(timeout, DEFAULT_BASH_TIMEOUT_MS)
113
+ : normalizeProcessTimeoutMs(timeout_ms, DEFAULT_BASH_TIMEOUT_MS);
131
114
  let prepared;
132
115
  try {
133
116
  prepared = await sandbox.prepareCommand({
134
117
  policy,
135
- engine: sandboxEngine ?? undefined,
136
- command: { command: "/bin/bash", args: ["-lc", command], cwd },
118
+ engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
119
+ command: {
120
+ command: "/bin/bash",
121
+ args: ["--noprofile", "--norc", "-c", command],
122
+ cwd,
123
+ env: cleanBashEnvironment(),
124
+ },
137
125
  });
138
- } catch (err) {
139
- return `Error: ${err?.message || String(err)}`;
126
+ } catch (error) {
127
+ return failed(`Error: ${error?.message || String(error)}`, "sandbox_prepare_failed", startedAt);
140
128
  }
129
+
141
130
  let result;
131
+ let cleanupError;
142
132
  try {
143
- result = await runCommand(prepared, { timeoutMs, signal });
133
+ result = await runPreparedProcess(prepared, {
134
+ timeoutMs,
135
+ signal,
136
+ maxBufferBytes: DEFAULT_PROCESS_BUFFER_BYTES,
137
+ });
144
138
  } finally {
145
- await prepared.cleanup?.();
139
+ try {
140
+ await prepared.cleanup?.();
141
+ } catch (error) {
142
+ cleanupError = error;
143
+ }
146
144
  }
147
- if (result.timedOut) return `Error: Command timed out after ${timeoutMs}ms`;
148
- if (result.aborted) return "Error: Command aborted";
149
- if (result.bufferExceeded) return `Error: Command output exceeded ${BASH_MAX_BUFFER_BYTES} bytes`;
150
- if (result.spawnError) return `Exit code 1:\n${result.spawnError.message}`;
151
- if (result.code && result.code !== 0) {
152
- return capChars(`Exit code ${result.code || 1}:\n${result.stdout || ""}${result.stderr || ""}`, {
153
- label: "Bash",
145
+
146
+ const baseOutcome = processOutcome(result, {
147
+ status: "ok",
148
+ code: "ok",
149
+ legacyTimeoutUsed,
150
+ });
151
+ const partial = combinedProcessOutput(result);
152
+ if (result.timedOut) {
153
+ return failedWithOutcome(
154
+ withPartial(`Error: Command timed out after ${timeoutMs}ms`, partial),
155
+ { ...baseOutcome, status: "error", code: "timeout", retryable: false, timedOut: true },
154
156
  maxChars,
155
- strategy: "head_tail",
156
- ctx,
157
- });
157
+ resolvedCtx,
158
+ );
159
+ }
160
+ if (result.aborted) {
161
+ return failedWithOutcome(
162
+ withPartial("Error: Command aborted", partial),
163
+ { ...baseOutcome, status: "error", code: "aborted", retryable: false },
164
+ maxChars,
165
+ resolvedCtx,
166
+ );
167
+ }
168
+ if (result.bufferExceeded) {
169
+ return failedWithOutcome(
170
+ withPartial(`Error: Command output exceeded ${DEFAULT_PROCESS_BUFFER_BYTES} bytes`, partial),
171
+ { ...baseOutcome, status: "error", code: "output_limit", retryable: false, truncated: true },
172
+ maxChars,
173
+ resolvedCtx,
174
+ );
175
+ }
176
+ if (result.spawnError) {
177
+ return failedWithOutcome(
178
+ withPartial(`Exit code 1:\n${result.spawnError.message}`, partial),
179
+ { ...baseOutcome, status: "error", code: "spawn_error", retryable: false, exitCode: 1 },
180
+ maxChars,
181
+ resolvedCtx,
182
+ );
183
+ }
184
+ if (result.code !== null && result.code !== 0) {
185
+ return failedWithOutcome(
186
+ withPartial(`Exit code ${result.code}`, partial),
187
+ { ...baseOutcome, status: "error", code: "nonzero_exit", retryable: false },
188
+ maxChars,
189
+ resolvedCtx,
190
+ );
191
+ }
192
+ if (result.signal) {
193
+ return failedWithOutcome(
194
+ withPartial(`Exit code 1:\nCommand terminated by ${result.signal}`, partial),
195
+ { ...baseOutcome, status: "error", code: "signal", retryable: false, exitCode: 1 },
196
+ maxChars,
197
+ resolvedCtx,
198
+ );
199
+ }
200
+ if (cleanupError) {
201
+ return failedWithOutcome(
202
+ withPartial(`Error: Sandbox cleanup failed: ${cleanupError?.message || String(cleanupError)}`, partial),
203
+ { ...baseOutcome, status: "error", code: "cleanup_failed", retryable: false },
204
+ maxChars,
205
+ resolvedCtx,
206
+ );
207
+ }
208
+
209
+ return completed(partial, baseOutcome, maxChars, "Bash", resolvedCtx);
210
+ }
211
+
212
+ function cleanBashEnvironment() {
213
+ const env = {
214
+ BASH_ENV: "/dev/null",
215
+ ENV: "/dev/null",
216
+ };
217
+ for (const key of Object.keys(process.env)) {
218
+ if (BASH_STARTUP_ENV_KEYS.has(key) || key.startsWith("BASH_FUNC_")) {
219
+ env[key] = undefined;
220
+ }
158
221
  }
159
- if (result.signal) return `Exit code 1:\nCommand terminated by ${result.signal}`;
160
- const output = result.stdout && result.stderr
161
- ? `STDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`
162
- : (result.stdout || result.stderr || "(no output)");
163
- return capChars(output, { label: "Bash", maxChars, strategy: "head_tail", ctx });
222
+ return env;
223
+ }
224
+
225
+ function finitePositiveInteger(value, fallback) {
226
+ const number = Number(value);
227
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
228
+ }
229
+
230
+ function processOutcome(result, extra = {}) {
231
+ return {
232
+ status: "ok",
233
+ code: "ok",
234
+ retryable: false,
235
+ attempts: 1,
236
+ durationMs: Number(result.durationMs) || 0,
237
+ bytes: Number(result.bytes) || 0,
238
+ truncated: !!result.truncated,
239
+ exitCode: result.code,
240
+ signal: result.signal,
241
+ timedOut: !!result.timedOut,
242
+ ...extra,
243
+ };
244
+ }
245
+
246
+ function withPartial(message, output) {
247
+ return output && output !== "(no output)" ? `${message}\n\nPartial output:\n${output}` : message;
248
+ }
249
+
250
+ function completed(text, outcome, maxChars, label, ctx) {
251
+ const raw = String(text || "(no output)");
252
+ const truncated = raw.length > maxChars || outcome.truncated;
253
+ return {
254
+ text: capChars(raw, { label, maxChars, strategy: "head_tail", ctx }),
255
+ outcome: { ...outcome, truncated },
256
+ error: false,
257
+ };
258
+ }
259
+
260
+ function failed(text, code, startedAt) {
261
+ return {
262
+ text,
263
+ outcome: {
264
+ status: "error",
265
+ code,
266
+ retryable: false,
267
+ attempts: 1,
268
+ durationMs: Date.now() - startedAt,
269
+ bytes: 0,
270
+ truncated: false,
271
+ exitCode: null,
272
+ signal: null,
273
+ timedOut: false,
274
+ },
275
+ error: true,
276
+ };
277
+ }
278
+
279
+ function failedWithOutcome(text, outcome, maxChars, ctx) {
280
+ const completedResult = completed(text, outcome, maxChars, "Bash", ctx);
281
+ return { ...completedResult, error: true };
164
282
  }
@@ -0,0 +1,238 @@
1
+ // @ts-check
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { passthroughSandbox } from "../sandbox-seam.js";
5
+ import { DEFAULT_MAX_BASH_OUTPUT_CHARS } from "./shared/constants.js";
6
+ import { normalizeProcessTimeoutMs } from "./bash.js";
7
+ import { capChars } from "./shared/output-truncation.js";
8
+ import {
9
+ isPathAllowed,
10
+ isWorkdirAllowed,
11
+ workspaceRoot,
12
+ } from "./shared/path-resolver.js";
13
+ import {
14
+ combinedProcessOutput,
15
+ DEFAULT_PROCESS_BUFFER_BYTES,
16
+ runPreparedProcess,
17
+ } from "./shared/process-runner.js";
18
+ import { readToolRuntime } from "./shared/runtime-context.js";
19
+ import { resolveSandboxPolicy } from "./shared/tool-context.js";
20
+
21
+ const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
22
+ const MAX_EXEC_ARGS = 256;
23
+
24
+ /**
25
+ * @param {{executable: string, args?: string[], workdir?: string, timeout_ms?: number, max_output_chars?: number}} params
26
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
27
+ */
28
+ export async function execToolImpl(params, options = {}) {
29
+ return (await execToolRun(params, options)).text;
30
+ }
31
+
32
+ /**
33
+ * Execute an argv vector directly, without shell parsing.
34
+ *
35
+ * @param {{executable: string, args?: string[], workdir?: string, timeout_ms?: number, max_output_chars?: number}} params
36
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
37
+ */
38
+ export async function execToolRun(
39
+ {
40
+ executable,
41
+ args = [],
42
+ workdir,
43
+ timeout_ms,
44
+ max_output_chars,
45
+ },
46
+ {
47
+ signal,
48
+ sandboxPolicy,
49
+ sandboxEngine,
50
+ ctx,
51
+ } = {},
52
+ ) {
53
+ const startedAt = Date.now();
54
+ const executableProblem = validateExecutable(executable);
55
+ if (executableProblem) return failed(executableProblem, "invalid_executable", startedAt);
56
+ const argsProblem = validateArgs(args);
57
+ if (argsProblem) return failed(argsProblem, "invalid_args", startedAt);
58
+
59
+ const resolvedCtx = ctx ?? readToolRuntime();
60
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
61
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
62
+ const pathOptions = { sandboxPolicy: policy, ctx: resolvedCtx };
63
+ if (workdir && !isWorkdirAllowed(workdir, pathOptions)) {
64
+ return failed(`Error: Working directory not allowed: ${workdir}`, "workdir_denied", startedAt);
65
+ }
66
+ const cwd = workspaceRoot(workdir, resolvedCtx);
67
+ if (!isPathAllowed(cwd, workdir, pathOptions)) {
68
+ return failed(`Error: Working directory not allowed: ${cwd}`, "workdir_denied", startedAt);
69
+ }
70
+ if (!existsSync(cwd)) {
71
+ return failed(`Error: Working directory not found: ${cwd}`, "workdir_not_found", startedAt);
72
+ }
73
+
74
+ const timeoutMs = normalizeProcessTimeoutMs(timeout_ms, DEFAULT_EXEC_TIMEOUT_MS);
75
+ const maxChars = positiveInteger(max_output_chars, DEFAULT_MAX_BASH_OUTPUT_CHARS);
76
+ let prepared;
77
+ try {
78
+ prepared = await sandbox.prepareCommand({
79
+ policy,
80
+ engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
81
+ command: {
82
+ command: executable,
83
+ args: [...args],
84
+ cwd,
85
+ },
86
+ });
87
+ } catch (error) {
88
+ return failed(`Error: ${error?.message || String(error)}`, "sandbox_prepare_failed", startedAt);
89
+ }
90
+
91
+ let result;
92
+ let cleanupError;
93
+ try {
94
+ result = await runPreparedProcess(prepared, {
95
+ timeoutMs,
96
+ signal,
97
+ maxBufferBytes: DEFAULT_PROCESS_BUFFER_BYTES,
98
+ });
99
+ } finally {
100
+ try {
101
+ await prepared.cleanup?.();
102
+ } catch (error) {
103
+ cleanupError = error;
104
+ }
105
+ }
106
+
107
+ const outcome = {
108
+ status: "ok",
109
+ code: "ok",
110
+ retryable: false,
111
+ attempts: 1,
112
+ durationMs: Number(result.durationMs) || 0,
113
+ bytes: Number(result.bytes) || 0,
114
+ truncated: !!result.truncated,
115
+ exitCode: result.code,
116
+ signal: result.signal,
117
+ timedOut: !!result.timedOut,
118
+ };
119
+ const partial = combinedProcessOutput(result);
120
+ if (result.timedOut) {
121
+ return finishError(
122
+ withPartial(`Error: Process timed out after ${timeoutMs}ms`, partial),
123
+ { ...outcome, status: "error", code: "timeout", timedOut: true },
124
+ maxChars,
125
+ resolvedCtx,
126
+ );
127
+ }
128
+ if (result.aborted) {
129
+ return finishError(
130
+ withPartial("Error: Process aborted", partial),
131
+ { ...outcome, status: "error", code: "aborted" },
132
+ maxChars,
133
+ resolvedCtx,
134
+ );
135
+ }
136
+ if (result.bufferExceeded) {
137
+ return finishError(
138
+ withPartial(`Error: Process output exceeded ${DEFAULT_PROCESS_BUFFER_BYTES} bytes`, partial),
139
+ { ...outcome, status: "error", code: "output_limit", truncated: true },
140
+ maxChars,
141
+ resolvedCtx,
142
+ );
143
+ }
144
+ if (result.spawnError) {
145
+ return finishError(
146
+ withPartial(`Exit code 1:\n${result.spawnError.message}`, partial),
147
+ { ...outcome, status: "error", code: "spawn_error", exitCode: 1 },
148
+ maxChars,
149
+ resolvedCtx,
150
+ );
151
+ }
152
+ if (result.code !== null && result.code !== 0) {
153
+ return finishError(
154
+ withPartial(`Exit code ${result.code}`, partial),
155
+ { ...outcome, status: "error", code: "nonzero_exit" },
156
+ maxChars,
157
+ resolvedCtx,
158
+ );
159
+ }
160
+ if (result.signal) {
161
+ return finishError(
162
+ withPartial(`Exit code 1:\nProcess terminated by ${result.signal}`, partial),
163
+ { ...outcome, status: "error", code: "signal", exitCode: 1 },
164
+ maxChars,
165
+ resolvedCtx,
166
+ );
167
+ }
168
+ if (cleanupError) {
169
+ return finishError(
170
+ withPartial(`Error: Sandbox cleanup failed: ${cleanupError?.message || String(cleanupError)}`, partial),
171
+ { ...outcome, status: "error", code: "cleanup_failed" },
172
+ maxChars,
173
+ resolvedCtx,
174
+ );
175
+ }
176
+ return finish(partial, outcome, maxChars, resolvedCtx, false);
177
+ }
178
+
179
+ function validateExecutable(value) {
180
+ if (typeof value !== "string" || value.trim().length === 0) {
181
+ return "Error: Exec executable must be a non-empty string.";
182
+ }
183
+ if (value.includes("\0") || value.includes("\n") || value.includes("\r")) {
184
+ return "Error: Exec executable contains an invalid character.";
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function validateArgs(value) {
190
+ if (!Array.isArray(value)) return "Error: Exec args must be an array of strings.";
191
+ if (value.length > MAX_EXEC_ARGS) return `Error: Exec accepts at most ${MAX_EXEC_ARGS} arguments.`;
192
+ if (value.some((entry) => typeof entry !== "string" || entry.includes("\0"))) {
193
+ return "Error: Exec args must contain only strings without NUL characters.";
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function positiveInteger(value, fallback) {
199
+ const number = Number(value);
200
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
201
+ }
202
+
203
+ function withPartial(message, output) {
204
+ return output && output !== "(no output)" ? `${message}\n\nPartial output:\n${output}` : message;
205
+ }
206
+
207
+ function finish(text, outcome, maxChars, ctx, error) {
208
+ const raw = String(text || "(no output)");
209
+ const truncated = raw.length > maxChars || outcome.truncated;
210
+ return {
211
+ text: capChars(raw, { label: "Exec", maxChars, strategy: "head_tail", ctx }),
212
+ outcome: { ...outcome, truncated },
213
+ error,
214
+ };
215
+ }
216
+
217
+ function finishError(text, outcome, maxChars, ctx) {
218
+ return finish(text, outcome, maxChars, ctx, true);
219
+ }
220
+
221
+ function failed(text, code, startedAt) {
222
+ return {
223
+ text,
224
+ outcome: {
225
+ status: "error",
226
+ code,
227
+ retryable: false,
228
+ attempts: 1,
229
+ durationMs: Date.now() - startedAt,
230
+ bytes: 0,
231
+ truncated: false,
232
+ exitCode: null,
233
+ signal: null,
234
+ timedOut: false,
235
+ },
236
+ error: true,
237
+ };
238
+ }
@@ -9,9 +9,16 @@ export { writeToolImpl } from "./write.js";
9
9
  export { editToolImpl } from "./edit.js";
10
10
  export { globToolImpl } from "./glob.js";
11
11
  export { grepToolImpl } from "./grep.js";
12
- export { bashToolImpl, normalizeBashTimeoutMs } from "./bash.js";
13
- export { webFetchToolImpl } from "./web-fetch.js";
14
- export { webSearchToolImpl } from "./web-search.js";
12
+ export {
13
+ bashToolImpl,
14
+ bashToolRun,
15
+ normalizeBashTimeoutMs,
16
+ normalizeProcessTimeoutMs,
17
+ } from "./bash.js";
18
+ export { execToolImpl, execToolRun } from "./exec.js";
19
+ export { webFetchToolImpl, performWebFetch } from "./web-fetch.js";
20
+ export { webSearchToolImpl, performWebSearch } from "./web-search.js";
21
+ export { createWebToolController } from "./web-controller.js";
15
22
 
16
23
  export { isPathAllowed, isWorkdirAllowed } from "./shared/path-resolver.js";
17
24
  export { resolveRgPath } from "./shared/ripgrep.js";