@robota-sdk/agent-tools 3.0.0-beta.79 → 3.0.0-beta.81
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 +39 -15
- package/dist/browser/browser.d.ts +14 -97
- package/dist/browser/browser.d.ts.map +1 -1
- package/dist/browser/browser.js +1 -1
- package/dist/browser/browser.js.map +1 -1
- package/dist/node/index.cjs +2356 -517
- package/dist/node/index.d.cts +1047 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +768 -131
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +2326 -510
- package/dist/node/index.js.map +1 -1
- package/package.json +33 -16
package/dist/node/index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, isAbsolute, join, posix, resolve
|
|
3
|
-
import {
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { killProcessTree } from "@robota-sdk/agent-process";
|
|
6
|
-
import { z } from "zod";
|
|
1
|
+
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
4
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { chmodSync, cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { FunctionTool, PROTECTED_DIRECTORY_NAMES, PROTECTED_FILE_NAMES, TOOL_SEARCH_TOOL_NAME, ToolExecutionError, ValidationError, createBoundedOutput, registerToolPermissionProfile, resolvePlatformShell, splitCommandSegments, subprocessTraceEnvironment, zodToJsonSchema } from "@robota-sdk/agent-core";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { killProcessTree } from "@robota-sdk/agent-process";
|
|
10
|
+
import { fetchWithEgressPolicy, isPathInside } from "@robota-sdk/agent-core/node";
|
|
8
11
|
import fg from "fast-glob";
|
|
9
12
|
import pLimit from "p-limit";
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import { Worker } from "node:worker_threads";
|
|
10
15
|
//#region src/sandbox/e2b-sandbox-client.ts
|
|
11
16
|
var E2BSandboxClient = class {
|
|
12
17
|
sandbox;
|
|
@@ -108,12 +113,42 @@ var InMemorySandboxClient = class {
|
|
|
108
113
|
}
|
|
109
114
|
};
|
|
110
115
|
//#endregion
|
|
116
|
+
//#region src/sandbox/containment.ts
|
|
117
|
+
function describeExecutionContainment(client) {
|
|
118
|
+
if (client === void 0) return "host";
|
|
119
|
+
return `sandbox-${client.filesystem ?? "separate"}`;
|
|
120
|
+
}
|
|
121
|
+
/** Whether file tools must read and write through the sandbox rather than the host filesystem. */
|
|
122
|
+
function routesFilesThroughSandbox(client) {
|
|
123
|
+
return describeExecutionContainment(client) === "sandbox-separate";
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/sandbox/manifest-enforceability.ts
|
|
127
|
+
/**
|
|
128
|
+
* Refuse a manifest whose security-bearing fields the built-in applicator cannot enforce.
|
|
129
|
+
*
|
|
130
|
+
* Emptiness is what is checked, not presence: `environment: {}` and `permissions: {}` request
|
|
131
|
+
* nothing, so refusing them would fail a caller that asked for no controls at all. `permissions`
|
|
132
|
+
* counts as empty when neither list has an entry — `{ read: [] }` is a declared-but-empty policy,
|
|
133
|
+
* not a policy.
|
|
134
|
+
*/
|
|
135
|
+
function refuseUnenforceableManifestControls(manifest) {
|
|
136
|
+
const unenforceable = [];
|
|
137
|
+
if (manifest.environment && Object.keys(manifest.environment).length > 0) unenforceable.push("environment");
|
|
138
|
+
const permissions = manifest.permissions;
|
|
139
|
+
const requestsSomething = (value) => Array.isArray(value) ? value.length > 0 : value !== void 0;
|
|
140
|
+
if (permissions && Object.values(permissions).some(requestsSomething)) unenforceable.push("permissions");
|
|
141
|
+
if (unenforceable.length === 0) return;
|
|
142
|
+
throw new Error(`workspace manifest requests ${unenforceable.join(" and ")}, which this sandbox client cannot enforce. The built-in applicator applies entries only. Supply a sandbox client that implements applyManifest and honours these fields, or remove them from the manifest — they were previously accepted and silently ignored, which reported a sandbox policy that was never applied (issue #2027).`);
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
111
145
|
//#region src/sandbox/workspace-manifest.ts
|
|
112
146
|
const DEFAULT_TARGET_ROOT = "/workspace";
|
|
113
147
|
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
114
148
|
const SHELL_QUOTE_PATTERN = /'/g;
|
|
115
149
|
async function applyWorkspaceManifest(sandboxClient, manifest, options = {}) {
|
|
116
150
|
if (sandboxClient.applyManifest) return sandboxClient.applyManifest(manifest, options);
|
|
151
|
+
refuseUnenforceableManifestControls(manifest);
|
|
117
152
|
const targetRoot = normalizeSandboxRoot(options.targetRoot ?? DEFAULT_TARGET_ROOT);
|
|
118
153
|
const appliedEntries = [];
|
|
119
154
|
for (const [rawPath, entry] of Object.entries(manifest.entries)) {
|
|
@@ -210,8 +245,20 @@ async function runSandboxCommand(sandboxClient, command) {
|
|
|
210
245
|
function resolveHostSourcePath(source, hostRoot) {
|
|
211
246
|
return isAbsolute(source) ? resolve(source) : resolve(hostRoot ?? process.cwd(), source);
|
|
212
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Remove every trailing `/`, by index scan.
|
|
250
|
+
*
|
|
251
|
+
* Not `replace(/\/+$/, '')`: that run has no start anchor, so the engine retries it from every offset inside the
|
|
252
|
+
* run and each retry re-scans to the end — 3.0 s on a 100 K run (`js/polynomial-redos`, SEC-003). The backslash
|
|
253
|
+
* conversion in {@link normalizeSandboxRoot} manufactures such a run from a Windows-style path.
|
|
254
|
+
*/
|
|
255
|
+
function trimTrailingSlashes(value) {
|
|
256
|
+
let end = value.length;
|
|
257
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
258
|
+
return value.slice(0, end);
|
|
259
|
+
}
|
|
213
260
|
function normalizeSandboxRoot(root) {
|
|
214
|
-
const normalized = root.replace(/\\/g, "/")
|
|
261
|
+
const normalized = trimTrailingSlashes(root.replace(/\\/g, "/"));
|
|
215
262
|
if (!normalized.startsWith("/")) throw new Error("workspace manifest targetRoot must be an absolute sandbox path");
|
|
216
263
|
return normalized.length === 0 ? "/" : normalized;
|
|
217
264
|
}
|
|
@@ -227,286 +274,740 @@ function assertUnreachable(value) {
|
|
|
227
274
|
throw new Error(`unsupported workspace manifest entry: ${JSON.stringify(value)}`);
|
|
228
275
|
}
|
|
229
276
|
//#endregion
|
|
230
|
-
//#region src/
|
|
277
|
+
//#region src/sandbox/os-sandbox-policy.ts
|
|
231
278
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
279
|
+
* What an OS-level sandbox lets a command touch, written once per backend (issue #3082).
|
|
280
|
+
*
|
|
281
|
+
* The same policy becomes bubblewrap arguments on Linux and a Seatbelt profile on macOS:
|
|
282
|
+
* - the whole filesystem is readable except the `denyRead` paths;
|
|
283
|
+
* - writes are allowed only inside the workspace, the temporary directories and `allowWrite`;
|
|
284
|
+
* - inside the workspace, the files that configure git, the agent, MCP servers and shells stay
|
|
285
|
+
* read-only, so a confined command cannot change what the next session trusts;
|
|
286
|
+
* - the network is either reachable or not. There is no per-domain allowlist: that needs a proxy
|
|
287
|
+
* process the OS cannot enforce, and a boundary here is only worth what the OS enforces.
|
|
234
288
|
*/
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
289
|
+
/** An isolated worktree's files are ordinary workspace files. */
|
|
290
|
+
const WRITABLE_INSIDE_PROTECTED = [".robota/worktrees", ".claude/worktrees"];
|
|
291
|
+
function join$1(root, relative) {
|
|
292
|
+
let end = root.length;
|
|
293
|
+
while (end > 0 && root[end - 1] === "/") end -= 1;
|
|
294
|
+
return `${root.slice(0, end)}/${relative}`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Workspace entries a confined command must not write, relative to the root. `.git` is read-only
|
|
298
|
+
* as a whole: the files that make git run something (config, hooks, `commondir`, per-worktree
|
|
299
|
+
* config) are too many and too easy to add to for a list inside it to stay complete, so git
|
|
300
|
+
* commands that write run unconfined, through the ordinary permission path.
|
|
301
|
+
*/
|
|
302
|
+
function protectedWorkspaceEntries() {
|
|
303
|
+
return [...PROTECTED_DIRECTORY_NAMES, ...PROTECTED_FILE_NAMES];
|
|
304
|
+
}
|
|
305
|
+
/** The `bwrap` argument vector that runs `command args` under the policy. */
|
|
306
|
+
function bubblewrapArguments(input) {
|
|
307
|
+
const { policy } = input;
|
|
308
|
+
const args = [
|
|
309
|
+
"--ro-bind",
|
|
310
|
+
"/",
|
|
311
|
+
"/",
|
|
312
|
+
"--dev",
|
|
313
|
+
"/dev",
|
|
314
|
+
"--proc",
|
|
315
|
+
"/proc"
|
|
316
|
+
];
|
|
317
|
+
for (const path of [
|
|
318
|
+
policy.root,
|
|
319
|
+
...policy.tempDirectories,
|
|
320
|
+
...policy.allowWrite
|
|
321
|
+
]) args.push("--bind-try", path, path);
|
|
322
|
+
for (const entry of protectedWorkspaceEntries()) {
|
|
323
|
+
const path = join$1(policy.root, entry);
|
|
324
|
+
if (input.exists(path)) args.push("--ro-bind", path, path);
|
|
325
|
+
}
|
|
326
|
+
for (const entry of WRITABLE_INSIDE_PROTECTED) {
|
|
327
|
+
const path = join$1(policy.root, entry);
|
|
328
|
+
if (!input.exists(path)) continue;
|
|
329
|
+
args.push("--bind", path, path);
|
|
330
|
+
for (const name of input.listDirectory(path)) {
|
|
331
|
+
const gitFile = join$1(path, `${name}/.git`);
|
|
332
|
+
if (input.exists(gitFile)) args.push("--ro-bind", gitFile, gitFile);
|
|
262
333
|
}
|
|
263
|
-
this.tools.delete(name);
|
|
264
|
-
logger.debug(`Tool "${name}" unregistered successfully`);
|
|
265
334
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return this.tools.get(name);
|
|
335
|
+
for (const hidden of policy.denyRead) {
|
|
336
|
+
if (!input.exists(hidden.path)) continue;
|
|
337
|
+
if (hidden.directory) args.push("--tmpfs", hidden.path);
|
|
338
|
+
else args.push("--ro-bind", "/dev/null", hidden.path);
|
|
271
339
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
getAll() {
|
|
276
|
-
return Array.from(this.tools.values());
|
|
340
|
+
if (!policy.network) {
|
|
341
|
+
if (input.seccompDescriptor === void 0) throw new Error("A sandbox without network needs the Unix-socket seccomp filter.");
|
|
342
|
+
args.push("--unshare-net", "--seccomp", String(input.seccompDescriptor));
|
|
277
343
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
344
|
+
args.push("--unshare-pid", "--die-with-parent", "--new-session", "--chdir", input.cwd);
|
|
345
|
+
args.push("--", input.command);
|
|
346
|
+
return [...args, ...input.args];
|
|
347
|
+
}
|
|
348
|
+
function regexEscape(path) {
|
|
349
|
+
return path.replace(/[\\^$.*+?()[\]{}|"]/g, (char) => `\\${char}`);
|
|
350
|
+
}
|
|
351
|
+
function quote(path) {
|
|
352
|
+
return `"${path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The Seatbelt profile for `sandbox-exec -p`. Later rules win, so the order below is the policy:
|
|
356
|
+
* deny writes, allow the writable places, deny the protected entries again, reopen worktrees.
|
|
357
|
+
*/
|
|
358
|
+
function seatbeltProfile(policy) {
|
|
359
|
+
const writable = [
|
|
360
|
+
policy.root,
|
|
361
|
+
...policy.tempDirectories,
|
|
362
|
+
...policy.allowWrite
|
|
363
|
+
].map((path) => `(subpath ${quote(path)})`).join(" ");
|
|
364
|
+
const protectedEntries = protectedWorkspaceEntries().map((entry) => {
|
|
365
|
+
const path = join$1(policy.root, entry);
|
|
366
|
+
return PROTECTED_FILE_NAMES.includes(entry) ? `(literal ${quote(path)})` : `(subpath ${quote(path)})`;
|
|
367
|
+
});
|
|
368
|
+
const worktrees = WRITABLE_INSIDE_PROTECTED.map((entry) => `(subpath ${quote(join$1(policy.root, entry))})`);
|
|
369
|
+
const pinned = [`(literal ${quote(join$1(policy.root, ".git"))})`, ...WRITABLE_INSIDE_PROTECTED.map((entry) => `(regex #"^${regexEscape(join$1(policy.root, entry))}/[^/]+/\\.git$")`)];
|
|
370
|
+
const lines = [
|
|
371
|
+
"(version 1)",
|
|
372
|
+
"(allow default)",
|
|
373
|
+
"(deny file-write*)",
|
|
374
|
+
`(allow file-write* ${writable} (literal "/dev/null") (regex #"^/dev/tty") (regex #"^/dev/fd/"))`,
|
|
375
|
+
`(deny file-write* ${protectedEntries.join(" ")})`,
|
|
376
|
+
`(allow file-write* ${worktrees.join(" ")})`,
|
|
377
|
+
`(deny file-write* ${pinned.join(" ")})`
|
|
378
|
+
];
|
|
379
|
+
if (policy.denyRead.length > 0) {
|
|
380
|
+
const hidden = policy.denyRead.map((entry) => entry.directory ? `(subpath ${quote(entry.path)})` : `(literal ${quote(entry.path)})`);
|
|
381
|
+
lines.push(`(deny file-read* ${hidden.join(" ")})`);
|
|
382
|
+
}
|
|
383
|
+
if (!policy.network) lines.push("(deny network*)");
|
|
384
|
+
return lines.join("\n");
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/sandbox/os-sandbox-seccomp.ts
|
|
388
|
+
/**
|
|
389
|
+
* The seccomp filter bubblewrap loads when a confined command has no network (issue #3082).
|
|
390
|
+
*
|
|
391
|
+
* `--unshare-net` removes every network interface, but a Unix socket is a file: a daemon listening
|
|
392
|
+
* on one outside the sandbox (a container engine, the session bus, an ssh agent) is still reachable
|
|
393
|
+
* through the read-only filesystem, and is a way to run anything on the host. The filter refuses
|
|
394
|
+
* creating an `AF_UNIX` socket, and refuses `io_uring_setup`, which could create one without the
|
|
395
|
+
* `socket` system call. A system call from another ABI (x32, 32-bit compat) is refused whole, since
|
|
396
|
+
* its numbers differ and the checks below would not see it. macOS's Seatbelt `(deny network*)`
|
|
397
|
+
* already covers Unix sockets.
|
|
398
|
+
*/
|
|
399
|
+
const BPF_LD_W_ABS = 32;
|
|
400
|
+
const BPF_JMP_JEQ_K = 21;
|
|
401
|
+
const BPF_JMP_JSET_K = 69;
|
|
402
|
+
const BPF_RET_K = 6;
|
|
403
|
+
const SECCOMP_RET_ALLOW = 2147418112;
|
|
404
|
+
const SECCOMP_RET_ERRNO = 327680;
|
|
405
|
+
const EPERM = 1;
|
|
406
|
+
const EAFNOSUPPORT = 97;
|
|
407
|
+
const AF_UNIX = 1;
|
|
408
|
+
const X32_SYSCALL_BIT = 1073741824;
|
|
409
|
+
/** `struct seccomp_data` offsets. */
|
|
410
|
+
const OFFSET_NR = 0;
|
|
411
|
+
const OFFSET_ARCH = 4;
|
|
412
|
+
const OFFSET_ARG0_LOW = 16;
|
|
413
|
+
const ARCHITECTURES = {
|
|
414
|
+
x64: {
|
|
415
|
+
audit: 3221225534,
|
|
416
|
+
socket: 41,
|
|
417
|
+
ioUringSetup: 425
|
|
418
|
+
},
|
|
419
|
+
arm64: {
|
|
420
|
+
audit: 3221225655,
|
|
421
|
+
socket: 198,
|
|
422
|
+
ioUringSetup: 425
|
|
293
423
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
424
|
+
};
|
|
425
|
+
function instruction(code, jt, jf, k) {
|
|
426
|
+
return [
|
|
427
|
+
code,
|
|
428
|
+
jt,
|
|
429
|
+
jf,
|
|
430
|
+
k
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The filter as bytes `bwrap --seccomp` reads, or `undefined` for a processor architecture it has
|
|
435
|
+
* no system call numbers for — the caller then refuses to confine rather than confine with a gap.
|
|
436
|
+
*/
|
|
437
|
+
function unixSocketSeccompFilter(arch = process.arch) {
|
|
438
|
+
const target = ARCHITECTURES[arch];
|
|
439
|
+
if (target === void 0) return void 0;
|
|
440
|
+
const errno = (code) => SECCOMP_RET_ERRNO | code;
|
|
441
|
+
const program = [
|
|
442
|
+
instruction(BPF_LD_W_ABS, 0, 0, OFFSET_ARCH),
|
|
443
|
+
instruction(BPF_JMP_JEQ_K, 1, 0, target.audit),
|
|
444
|
+
instruction(BPF_RET_K, 0, 0, errno(EPERM)),
|
|
445
|
+
instruction(BPF_LD_W_ABS, 0, 0, OFFSET_NR),
|
|
446
|
+
instruction(BPF_JMP_JSET_K, 0, 1, X32_SYSCALL_BIT),
|
|
447
|
+
instruction(BPF_RET_K, 0, 0, errno(EPERM)),
|
|
448
|
+
instruction(BPF_JMP_JEQ_K, 0, 1, target.ioUringSetup),
|
|
449
|
+
instruction(BPF_RET_K, 0, 0, errno(EPERM)),
|
|
450
|
+
instruction(BPF_JMP_JEQ_K, 0, 3, target.socket),
|
|
451
|
+
instruction(BPF_LD_W_ABS, 0, 0, OFFSET_ARG0_LOW),
|
|
452
|
+
instruction(BPF_JMP_JEQ_K, 0, 1, AF_UNIX),
|
|
453
|
+
instruction(BPF_RET_K, 0, 0, errno(EAFNOSUPPORT)),
|
|
454
|
+
instruction(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW)
|
|
455
|
+
];
|
|
456
|
+
const bytes = new Uint8Array(program.length * 8);
|
|
457
|
+
const view = new DataView(bytes.buffer);
|
|
458
|
+
program.forEach(([code, jt, jf, k], index) => {
|
|
459
|
+
view.setUint16(index * 8, code, true);
|
|
460
|
+
view.setUint8(index * 8 + 2, jt);
|
|
461
|
+
view.setUint8(index * 8 + 3, jf);
|
|
462
|
+
view.setUint32(index * 8 + 4, k >>> 0, true);
|
|
463
|
+
});
|
|
464
|
+
return bytes;
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/sandbox/os-sandbox-client.ts
|
|
468
|
+
/**
|
|
469
|
+
* OS-level confinement of shell commands over the host filesystem (issue #3082): bubblewrap on
|
|
470
|
+
* Linux and WSL2, Seatbelt (`sandbox-exec`) on macOS. Other platforms have no backend; the client
|
|
471
|
+
* reports that instead of pretending.
|
|
472
|
+
*
|
|
473
|
+
* It is a `shared` sandbox client: file tools stay on the host under the path guard, and the shell
|
|
474
|
+
* tool starts the wrapped invocation itself. Settings are live — `/sandbox` changes them for the
|
|
475
|
+
* next command without rebuilding the session.
|
|
476
|
+
*/
|
|
477
|
+
const DEFAULT_OS_SANDBOX_SETTINGS = Object.freeze({
|
|
478
|
+
enabled: false,
|
|
479
|
+
autoAllowBashIfSandboxed: true,
|
|
480
|
+
excludedCommands: [],
|
|
481
|
+
allowWrite: [],
|
|
482
|
+
denyRead: [],
|
|
483
|
+
network: false
|
|
484
|
+
});
|
|
485
|
+
const SEATBELT_EXECUTABLE = "/usr/bin/sandbox-exec";
|
|
486
|
+
function defaultProbe(command, args) {
|
|
487
|
+
const result = spawnSync(command, [...args], {
|
|
488
|
+
timeout: 5e3,
|
|
489
|
+
encoding: "utf8"
|
|
490
|
+
});
|
|
491
|
+
if (result.error !== void 0) return {
|
|
492
|
+
ok: false,
|
|
493
|
+
detail: result.error.message
|
|
494
|
+
};
|
|
495
|
+
const detail = (result.stderr ?? "").trim().split("\n")[0];
|
|
496
|
+
return result.status === 0 ? { ok: true } : {
|
|
497
|
+
ok: false,
|
|
498
|
+
...detail ? { detail } : {}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** Find the platform's backend and check it can actually start a sandbox here. */
|
|
502
|
+
function detectOsSandbox(options = {}) {
|
|
503
|
+
const platform = options.platform ?? process.platform;
|
|
504
|
+
const probe = options.probe ?? defaultProbe;
|
|
505
|
+
if (platform === "linux") {
|
|
506
|
+
if (unixSocketSeccompFilter(options.arch ?? process.arch) === void 0) return {
|
|
507
|
+
backend: "bubblewrap",
|
|
508
|
+
missing: [`a seccomp filter for ${options.arch ?? process.arch} (x64 and arm64 are supported)`]
|
|
509
|
+
};
|
|
510
|
+
const check = probe("bwrap", [
|
|
511
|
+
"--ro-bind",
|
|
512
|
+
"/",
|
|
513
|
+
"/",
|
|
514
|
+
"--dev",
|
|
515
|
+
"/dev",
|
|
516
|
+
"--unshare-pid",
|
|
517
|
+
"true"
|
|
518
|
+
]);
|
|
519
|
+
if (check.ok) return {
|
|
520
|
+
backend: "bubblewrap",
|
|
521
|
+
executable: "bwrap",
|
|
522
|
+
missing: []
|
|
523
|
+
};
|
|
524
|
+
return {
|
|
525
|
+
backend: "bubblewrap",
|
|
526
|
+
missing: [check.detail?.includes("ENOENT") ? "bubblewrap (install the `bubblewrap` package)" : `bubblewrap cannot create a sandbox here${check.detail ? `: ${check.detail}` : ""}`]
|
|
527
|
+
};
|
|
299
528
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
529
|
+
if (platform === "darwin") {
|
|
530
|
+
const check = probe(SEATBELT_EXECUTABLE, [
|
|
531
|
+
"-p",
|
|
532
|
+
"(version 1)(allow default)",
|
|
533
|
+
"/usr/bin/true"
|
|
534
|
+
]);
|
|
535
|
+
if (check.ok) return {
|
|
536
|
+
backend: "seatbelt",
|
|
537
|
+
executable: SEATBELT_EXECUTABLE,
|
|
538
|
+
missing: []
|
|
539
|
+
};
|
|
540
|
+
return {
|
|
541
|
+
backend: "seatbelt",
|
|
542
|
+
missing: [`sandbox-exec cannot run${check.detail ? `: ${check.detail}` : ""}`]
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
missing: [],
|
|
547
|
+
unsupportedPlatform: platform
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function realPathOrSelf(path) {
|
|
551
|
+
try {
|
|
552
|
+
return realpathSync(path);
|
|
553
|
+
} catch {
|
|
554
|
+
return path;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function isDirectory(path) {
|
|
558
|
+
try {
|
|
559
|
+
return statSync(path).isDirectory();
|
|
560
|
+
} catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** Give the owner read, write and search permission throughout an entry, never following links. */
|
|
565
|
+
function grantOwnerAccess(path) {
|
|
566
|
+
const stat = lstatSync(path);
|
|
567
|
+
if (stat.isSymbolicLink()) return;
|
|
568
|
+
chmodSync(path, stat.mode | 448);
|
|
569
|
+
if (!stat.isDirectory()) return;
|
|
570
|
+
for (const name of readdirSync(path)) grantOwnerAccess(`${path}/${name}`);
|
|
571
|
+
}
|
|
572
|
+
function isSymbolicLink(path) {
|
|
573
|
+
try {
|
|
574
|
+
return lstatSync(path).isSymbolicLink();
|
|
575
|
+
} catch {
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/** The program a shell line starts first — what `excludedCommands` names. */
|
|
580
|
+
function firstProgram(shellCommand) {
|
|
581
|
+
return shellCommand.trim().split(/\s+/)[0];
|
|
582
|
+
}
|
|
583
|
+
var OsSandboxClient = class {
|
|
584
|
+
filesystem = "shared";
|
|
585
|
+
root;
|
|
586
|
+
availability;
|
|
587
|
+
homeDirectory;
|
|
588
|
+
current;
|
|
589
|
+
inFlight = 0;
|
|
590
|
+
baseline = [];
|
|
591
|
+
/** Entries a clean-up could not restore, with the state they must return to. */
|
|
592
|
+
unresolved = /* @__PURE__ */ new Map();
|
|
593
|
+
constructor(options) {
|
|
594
|
+
this.root = realPathOrSelf(options.root);
|
|
595
|
+
this.availability = options.availability;
|
|
596
|
+
this.homeDirectory = options.homeDirectory ?? homedir();
|
|
597
|
+
this.current = {
|
|
598
|
+
...DEFAULT_OS_SANDBOX_SETTINGS,
|
|
599
|
+
...options.settings
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
status() {
|
|
603
|
+
return {
|
|
604
|
+
settings: this.current,
|
|
605
|
+
availability: this.availability,
|
|
606
|
+
active: this.current.enabled && this.availability.executable !== void 0
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** Change the settings for the next command. */
|
|
610
|
+
configure(settings) {
|
|
611
|
+
this.current = {
|
|
612
|
+
...this.current,
|
|
613
|
+
...settings
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/** Whether `shellCommand` would run confined. */
|
|
617
|
+
confines(shellCommand) {
|
|
618
|
+
if (!this.status().active) return false;
|
|
619
|
+
if (splitCommandSegments(shellCommand).length !== 1) return true;
|
|
620
|
+
const program = firstProgram(shellCommand);
|
|
621
|
+
return program === void 0 || !this.current.excludedCommands.includes(program);
|
|
622
|
+
}
|
|
623
|
+
autoApproves(shellCommand) {
|
|
624
|
+
if (!this.current.autoAllowBashIfSandboxed || !this.confines(shellCommand)) return false;
|
|
625
|
+
if (this.unresolved.size > 0) return false;
|
|
626
|
+
return !this.protectedEntryStates().some((state) => state.kind === "symlink" && !this.resolvesOutsideWritableWorkspace(state.path));
|
|
627
|
+
}
|
|
628
|
+
wrapCommand(invocation, shellCommand) {
|
|
629
|
+
if (!this.confines(shellCommand)) return invocation;
|
|
630
|
+
const policy = this.policy();
|
|
631
|
+
const executable = this.availability.executable;
|
|
632
|
+
if (this.availability.backend === "seatbelt") return {
|
|
633
|
+
command: executable,
|
|
634
|
+
args: [
|
|
635
|
+
"-p",
|
|
636
|
+
seatbeltProfile(policy),
|
|
637
|
+
invocation.command,
|
|
638
|
+
...invocation.args
|
|
639
|
+
],
|
|
640
|
+
cwd: invocation.cwd
|
|
641
|
+
};
|
|
642
|
+
const filter = policy.network ? void 0 : unixSocketSeccompFilter();
|
|
643
|
+
if (!this.protectedEntryStates().some((state) => state.path.endsWith("/.robota") && state.kind === "symlink")) mkdirSync(`${this.root}/.robota`, { recursive: true });
|
|
644
|
+
const args = bubblewrapArguments({
|
|
645
|
+
policy,
|
|
646
|
+
exists: (path) => existsSync(path) && !isSymbolicLink(path),
|
|
647
|
+
listDirectory: (path) => readdirSync(path),
|
|
648
|
+
cwd: invocation.cwd,
|
|
649
|
+
command: invocation.command,
|
|
650
|
+
args: invocation.args,
|
|
651
|
+
...filter !== void 0 ? { seccompDescriptor: 3 } : {}
|
|
652
|
+
});
|
|
653
|
+
if (this.inFlight === 0) this.baseline = this.protectedEntryStates().map((state) => this.unresolved.get(state.path) ?? state);
|
|
654
|
+
this.inFlight += 1;
|
|
655
|
+
let finished = false;
|
|
656
|
+
return {
|
|
657
|
+
command: executable,
|
|
658
|
+
args,
|
|
659
|
+
cwd: invocation.cwd,
|
|
660
|
+
...filter !== void 0 ? { inputDescriptors: [filter] } : {},
|
|
661
|
+
afterExit: () => {
|
|
662
|
+
if (finished) return void 0;
|
|
663
|
+
finished = true;
|
|
664
|
+
this.inFlight -= 1;
|
|
665
|
+
return this.restoreProtectedEntries(this.baseline);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
307
668
|
}
|
|
308
669
|
/**
|
|
309
|
-
*
|
|
670
|
+
* How each protected entry stands before a command: bubblewrap can mount an existing entry
|
|
671
|
+
* read-only, but not one that does not exist yet, and a symlink it mounts through to its target
|
|
672
|
+
* while the link itself stays replaceable. Read with `lstat`, so a dangling link is not "missing".
|
|
310
673
|
*/
|
|
311
|
-
|
|
312
|
-
return
|
|
674
|
+
protectedEntryStates() {
|
|
675
|
+
return protectedWorkspaceEntries().map((entry) => {
|
|
676
|
+
const path = `${this.root}/${entry}`;
|
|
677
|
+
try {
|
|
678
|
+
return lstatSync(path).isSymbolicLink() ? {
|
|
679
|
+
path,
|
|
680
|
+
kind: "symlink",
|
|
681
|
+
target: readlinkSync(path)
|
|
682
|
+
} : {
|
|
683
|
+
path,
|
|
684
|
+
kind: "present"
|
|
685
|
+
};
|
|
686
|
+
} catch {
|
|
687
|
+
return {
|
|
688
|
+
path,
|
|
689
|
+
kind: "missing"
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
});
|
|
313
693
|
}
|
|
314
694
|
/**
|
|
315
|
-
*
|
|
695
|
+
* Undo what the command did to protected entries it could reach: one it created where none
|
|
696
|
+
* existed is moved into `.robota/sandbox-quarantine`, and a symlink it replaced is restored. Moved,
|
|
697
|
+
* not deleted, so nothing the host wrote meanwhile is lost.
|
|
316
698
|
*/
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
699
|
+
/** Whether a path's real location is under the read-only mounts: not the workspace, temp or `allowWrite`. */
|
|
700
|
+
resolvesOutsideWritableWorkspace(path) {
|
|
701
|
+
let real;
|
|
702
|
+
try {
|
|
703
|
+
real = realpathSync(path);
|
|
704
|
+
} catch {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
const policy = this.policy();
|
|
708
|
+
return ![
|
|
709
|
+
policy.root,
|
|
710
|
+
...policy.tempDirectories,
|
|
711
|
+
...policy.allowWrite
|
|
712
|
+
].some((area) => real === area || real.startsWith(`${area}/`));
|
|
320
713
|
}
|
|
321
714
|
/**
|
|
322
|
-
*
|
|
715
|
+
* Never throws: this runs as the command's process closes, and an exception there would take the
|
|
716
|
+
* host down and leave the entry in place. The quarantine is outside the workspace, under the
|
|
717
|
+
* user's `~/.robota`, where the command cannot reach it; what cannot be moved there is removed.
|
|
323
718
|
*/
|
|
324
|
-
|
|
325
|
-
|
|
719
|
+
restoreProtectedEntries(before) {
|
|
720
|
+
const quarantine = `${this.quarantineRoot(before)}/${Date.now()}-${randomUUID()}`;
|
|
721
|
+
const notes = [];
|
|
722
|
+
for (const state of before) {
|
|
723
|
+
if (state.kind === "present") continue;
|
|
724
|
+
try {
|
|
725
|
+
const now = this.protectedEntryStates().find((entry) => entry.path === state.path);
|
|
726
|
+
if (state.kind === "missing" && now.kind === "missing") {
|
|
727
|
+
this.unresolved.delete(state.path);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (state.kind === "symlink" && now.kind === "symlink" && now.target === state.target) {
|
|
731
|
+
this.unresolved.delete(state.path);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (now.kind !== "missing") notes.push(this.setAside(state.path, quarantine));
|
|
735
|
+
if (state.kind === "symlink") symlinkSync(state.target, state.path);
|
|
736
|
+
this.unresolved.delete(state.path);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
this.unresolved.set(state.path, state);
|
|
739
|
+
notes.push(`could not restore ${state.path} (${error instanceof Error ? error.message : String(error)}); commands will ask until it is removed`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (notes.length === 0) return void 0;
|
|
743
|
+
return `[sandbox] A confined command may not create or replace git, agent, MCP or shell configuration: ${notes.join("; ")}.`;
|
|
326
744
|
}
|
|
327
745
|
/**
|
|
328
|
-
*
|
|
746
|
+
* Where set-aside entries go: the workspace's own `.robota`, when it was a real directory before
|
|
747
|
+
* the command — then it was mounted read-only, so the command could not reach it, and a rename
|
|
748
|
+
* within one filesystem needs no permission inside the entry. Otherwise the user's `~/.robota`.
|
|
749
|
+
* Decided from the baseline: what is there now may be the command's own replacement.
|
|
329
750
|
*/
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
751
|
+
quarantineRoot(before) {
|
|
752
|
+
const robota = `${this.root}/.robota`;
|
|
753
|
+
return before.some((state) => state.path === robota && state.kind === "present") ? `${robota}/sandbox-quarantine` : `${this.homeDirectory}/.robota/sandbox-quarantine`;
|
|
754
|
+
}
|
|
755
|
+
setAside(path, quarantine) {
|
|
756
|
+
const destination = `${quarantine}/${basename(path)}`;
|
|
757
|
+
mkdirSync(quarantine, { recursive: true });
|
|
758
|
+
try {
|
|
759
|
+
renameSync(path, destination);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
if (this.inFlight > 0) throw error;
|
|
762
|
+
grantOwnerAccess(path);
|
|
763
|
+
if (error.code === "EXDEV") {
|
|
764
|
+
cpSync(path, destination, {
|
|
765
|
+
recursive: true,
|
|
766
|
+
verbatimSymlinks: true
|
|
767
|
+
});
|
|
768
|
+
rmSync(path, {
|
|
769
|
+
recursive: true,
|
|
770
|
+
force: true
|
|
771
|
+
});
|
|
772
|
+
} else renameSync(path, destination);
|
|
349
773
|
}
|
|
774
|
+
return `moved ${path} to ${destination}`;
|
|
350
775
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
776
|
+
/** The policy for the current settings, with every path made absolute and real. */
|
|
777
|
+
policy() {
|
|
778
|
+
const absolute = (path) => {
|
|
779
|
+
const expanded = path === "~" || path.startsWith("~/") ? `${this.homeDirectory}${path.slice(1)}` : path;
|
|
780
|
+
return realPathOrSelf(isAbsolute(expanded) ? expanded : resolve(this.root, expanded));
|
|
781
|
+
};
|
|
782
|
+
const temp = [...new Set([tmpdir(), "/tmp"].filter(existsSync).map(realPathOrSelf))];
|
|
783
|
+
return {
|
|
784
|
+
root: this.root,
|
|
785
|
+
tempDirectories: temp,
|
|
786
|
+
allowWrite: this.current.allowWrite.map(absolute),
|
|
787
|
+
denyRead: this.current.denyRead.map((path) => {
|
|
788
|
+
const resolved = absolute(path);
|
|
789
|
+
return {
|
|
790
|
+
path: resolved,
|
|
791
|
+
directory: isDirectory(resolved)
|
|
792
|
+
};
|
|
793
|
+
}),
|
|
794
|
+
network: this.current.network
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
run(command, options = {}) {
|
|
798
|
+
const shell = resolvePlatformShell();
|
|
799
|
+
const cwd = options.workingDirectory ?? this.root;
|
|
800
|
+
const invocation = this.wrapCommand({
|
|
801
|
+
command: shell.command,
|
|
802
|
+
args: shell.commandArgs(command),
|
|
803
|
+
cwd
|
|
804
|
+
}, command);
|
|
805
|
+
return new Promise((resolveRun, reject) => {
|
|
806
|
+
const extra = invocation.inputDescriptors ?? [];
|
|
807
|
+
let child;
|
|
808
|
+
try {
|
|
809
|
+
child = spawn(invocation.command, [...invocation.args], {
|
|
810
|
+
cwd: invocation.cwd,
|
|
811
|
+
stdio: [
|
|
812
|
+
"ignore",
|
|
813
|
+
"pipe",
|
|
814
|
+
"pipe",
|
|
815
|
+
...extra.map(() => "pipe")
|
|
816
|
+
],
|
|
817
|
+
...options.timeoutMs !== void 0 ? { timeout: options.timeoutMs } : {}
|
|
818
|
+
});
|
|
819
|
+
} catch (error) {
|
|
820
|
+
invocation.afterExit?.();
|
|
821
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
822
|
+
return;
|
|
374
823
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
824
|
+
extra.forEach((data, index) => {
|
|
825
|
+
const stream = child.stdio[index + 3];
|
|
826
|
+
stream?.on("error", () => void 0);
|
|
827
|
+
stream?.end(Buffer.from(data));
|
|
828
|
+
});
|
|
829
|
+
let stdout = "";
|
|
830
|
+
let stderr = "";
|
|
831
|
+
child.stdout?.on("data", (chunk) => stdout += chunk.toString());
|
|
832
|
+
child.stderr?.on("data", (chunk) => stderr += chunk.toString());
|
|
833
|
+
child.on("error", (error) => {
|
|
834
|
+
invocation.afterExit?.();
|
|
835
|
+
reject(error);
|
|
836
|
+
});
|
|
837
|
+
child.on("close", (code) => {
|
|
838
|
+
const note = invocation.afterExit?.();
|
|
839
|
+
resolveRun({
|
|
840
|
+
stdout: note === void 0 ? stdout : `${stdout}\n${note}`,
|
|
841
|
+
...stderr ? { stderr } : {},
|
|
842
|
+
exitCode: code ?? 1
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
});
|
|
379
846
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
let isValidEnum = false;
|
|
383
|
-
for (const enumValue of enumValues) if (value === enumValue) {
|
|
384
|
-
isValidEnum = true;
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
|
-
if (!isValidEnum) return `Parameter "${key}" must be one of: ${enumValues.join(", ")}, got ${value}`;
|
|
847
|
+
readFile(path) {
|
|
848
|
+
return Promise.resolve(readFileSync(path, "utf8"));
|
|
388
849
|
}
|
|
850
|
+
writeFile(path, content) {
|
|
851
|
+
writeFileSync(path, content, "utf8");
|
|
852
|
+
return Promise.resolve();
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
//#endregion
|
|
856
|
+
//#region src/retrieval/repo-map-index.ts
|
|
857
|
+
/** Persisted-schema version — bump when `IRepoMapIndex`'s serialized shape changes incompatibly. */
|
|
858
|
+
const REPO_MAP_INDEX_VERSION = 1;
|
|
859
|
+
/** Parse one corpus file into an index entry. */
|
|
860
|
+
function parseEntry(parser, file) {
|
|
861
|
+
const parsed = parser.parse(file.path, file.content);
|
|
862
|
+
return {
|
|
863
|
+
path: file.path,
|
|
864
|
+
definitions: parsed.definitions,
|
|
865
|
+
references: parsed.references
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
/** Parse the whole corpus once into a serializable repo-map index. */
|
|
869
|
+
function buildRepoMapIndex(options) {
|
|
870
|
+
return {
|
|
871
|
+
version: 1,
|
|
872
|
+
entries: options.corpus.map((file) => parseEntry(options.parser, file))
|
|
873
|
+
};
|
|
389
874
|
}
|
|
390
875
|
/**
|
|
391
|
-
*
|
|
876
|
+
* Apply corpus changes to a built index INCREMENTALLY (SELFHOST-003 P3): re-parse only the `upserted`
|
|
877
|
+
* files and drop `removed` paths, reusing every unchanged entry. Returns a new index (the input is not
|
|
878
|
+
* mutated). A file present in both `removed` and `upserted` is upserted (re-parse wins); a path repeated
|
|
879
|
+
* within `upserted` is de-duplicated last-wins, so the result always has one entry per path — matching a
|
|
880
|
+
* full rebuild (entry order does not affect ranking). Unchanged entries are REUSED BY REFERENCE; index
|
|
881
|
+
* entries are treated as immutable, so callers must not mutate an entry in place.
|
|
392
882
|
*/
|
|
393
|
-
function
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
const typeError = validateParameterType(key, value, paramSchema);
|
|
409
|
-
if (typeError) errors.push(typeError);
|
|
410
|
-
}
|
|
411
|
-
return errors;
|
|
883
|
+
function updateRepoMapIndex(index, changes, parser) {
|
|
884
|
+
const upsertedByPath = new Map((changes.upserted ?? []).map((file) => [file.path, file]));
|
|
885
|
+
const touched = /* @__PURE__ */ new Set([...changes.removed ?? [], ...upsertedByPath.keys()]);
|
|
886
|
+
const kept = index.entries.filter((entry) => !touched.has(entry.path));
|
|
887
|
+
const upserted = [...upsertedByPath.values()].map((file) => parseEntry(parser, file));
|
|
888
|
+
return {
|
|
889
|
+
version: index.version,
|
|
890
|
+
entries: [...kept, ...upserted]
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/** Serialize a built index to a neutral JSON string for persistence by the surface. */
|
|
894
|
+
function serializeRepoMapIndex(index) {
|
|
895
|
+
return JSON.stringify(index);
|
|
412
896
|
}
|
|
413
897
|
/**
|
|
414
|
-
*
|
|
898
|
+
* Restore a built index from its serialized form. Throws on malformed JSON or an unsupported
|
|
899
|
+
* `version` — a stale/incompatible persisted index must be rebuilt, never silently mis-ranked.
|
|
415
900
|
*/
|
|
416
|
-
function
|
|
417
|
-
const
|
|
901
|
+
function deserializeRepoMapIndex(serialized) {
|
|
902
|
+
const parsed = JSON.parse(serialized);
|
|
903
|
+
if (parsed.version !== 1) throw new Error(`Unsupported repo-map index version ${String(parsed.version)} (expected 1); rebuild the index.`);
|
|
904
|
+
if (!Array.isArray(parsed.entries)) throw new Error("Malformed repo-map index: missing `entries`.");
|
|
905
|
+
for (const entry of parsed.entries) if (typeof entry?.path !== "string" || !Array.isArray(entry?.definitions) || !Array.isArray(entry?.references)) throw new Error("Malformed repo-map index: a corrupt entry — rebuild the index.");
|
|
418
906
|
return {
|
|
419
|
-
|
|
420
|
-
|
|
907
|
+
version: parsed.version,
|
|
908
|
+
entries: parsed.entries
|
|
421
909
|
};
|
|
422
910
|
}
|
|
423
911
|
//#endregion
|
|
424
|
-
//#region src/
|
|
912
|
+
//#region src/retrieval/repo-map-adapter.ts
|
|
425
913
|
/**
|
|
426
|
-
*
|
|
427
|
-
* Wraps a JavaScript function as a tool with schema validation
|
|
914
|
+
* SELFHOST-003: neutral repo-map ranking adapter — mirrors `InMemorySandboxClient`.
|
|
428
915
|
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
const executionTime = Date.now() - startTime;
|
|
473
|
-
return {
|
|
474
|
-
success: true,
|
|
475
|
-
data: result,
|
|
476
|
-
metadata: {
|
|
477
|
-
executionTime,
|
|
478
|
-
toolName,
|
|
479
|
-
parameters
|
|
916
|
+
* Ranks a corpus of source files by graph centrality relative to the active files / mentioned
|
|
917
|
+
* identifiers, within a token budget. It is a NEUTRAL mechanism: it works on ANY repo given a corpus
|
|
918
|
+
* and an injected source parser — it carries no repo paths and no domain content. The heavy parser is
|
|
919
|
+
* injected as the duck-typed `IRetrievalSourceParser` (like `E2BSandboxClient` duck-types the E2B SDK),
|
|
920
|
+
* and the corpus is supplied from the surface.
|
|
921
|
+
*
|
|
922
|
+
* P2 (index build + persistence): the corpus is parsed ONCE into an `IRepoMapIndex` at construction
|
|
923
|
+
* (or supplied prebuilt/persisted via `{ index }`), so `retrieve()` ranks without re-parsing.
|
|
924
|
+
*
|
|
925
|
+
* Ranking model (aider repo-map style): a definition's score is the weighted number of references to it
|
|
926
|
+
* across the corpus, references FROM an active file weighted higher (personalization), plus a boost for
|
|
927
|
+
* a directly-mentioned identifier. Entries are emitted most-relevant-first, truncated to the budget.
|
|
928
|
+
*/
|
|
929
|
+
/** References from an active file weigh more (personalization toward the current focus). */
|
|
930
|
+
const ACTIVE_FILE_WEIGHT = 3;
|
|
931
|
+
/** A directly-mentioned identifier is a strong relevance signal. */
|
|
932
|
+
const MENTION_BOOST = 5;
|
|
933
|
+
/**
|
|
934
|
+
* Estimate the token cost of one repo-map entry (neutral chars/4 heuristic). Uses the same rendering
|
|
935
|
+
* shape the tool prints (`file:line kind name`) so the budgeted estimate matches the emitted output.
|
|
936
|
+
*/
|
|
937
|
+
function estimateTokens(symbol) {
|
|
938
|
+
const line = `${symbol.file}:${symbol.line} ${symbol.kind} ${symbol.name}`;
|
|
939
|
+
return Math.max(1, Math.ceil(line.length / 4));
|
|
940
|
+
}
|
|
941
|
+
const symbolKey = (s) => `${s.file}::${s.name}::${s.line}`;
|
|
942
|
+
var RepoMapRetrievalAdapter = class {
|
|
943
|
+
index;
|
|
944
|
+
constructor(options) {
|
|
945
|
+
if (options.index) this.index = options.index;
|
|
946
|
+
else if (options.parser && options.corpus) this.index = buildRepoMapIndex({
|
|
947
|
+
parser: options.parser,
|
|
948
|
+
corpus: options.corpus
|
|
949
|
+
});
|
|
950
|
+
else throw new Error("RepoMapRetrievalAdapter requires either { index } or { parser, corpus }.");
|
|
951
|
+
}
|
|
952
|
+
async retrieve(request) {
|
|
953
|
+
return selectWithinBudget(rankSymbols(this.index.entries.map((entry) => ({
|
|
954
|
+
file: entry.path,
|
|
955
|
+
parsed: {
|
|
956
|
+
definitions: entry.definitions,
|
|
957
|
+
references: entry.references
|
|
480
958
|
}
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Validate parameters (simple boolean result)
|
|
485
|
-
*/
|
|
486
|
-
validate(parameters) {
|
|
487
|
-
return getValidationErrors(parameters, this.schema.parameters.required || [], this.schema.parameters.properties || {}, this.schema.parameters.additionalProperties).length === 0;
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Validate tool parameters with detailed result
|
|
491
|
-
*/
|
|
492
|
-
validateParameters(parameters) {
|
|
493
|
-
return validateToolParameters(parameters, this.schema.parameters.required || [], this.schema.parameters.properties || {}, this.schema.parameters.additionalProperties);
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* Get tool description
|
|
497
|
-
*/
|
|
498
|
-
getDescription() {
|
|
499
|
-
return this.schema.description;
|
|
500
|
-
}
|
|
501
|
-
/**
|
|
502
|
-
* Validate constructor inputs
|
|
503
|
-
*/
|
|
504
|
-
validateConstructorInputs() {
|
|
505
|
-
if (!this.schema) throw new ValidationError("Tool schema is required");
|
|
506
|
-
if (!this.fn || typeof this.fn !== "function") throw new ValidationError("Tool function is required and must be a function");
|
|
507
|
-
if (!this.schema.name) throw new ValidationError("Tool schema must have a name");
|
|
959
|
+
})), request), request.tokenBudget);
|
|
508
960
|
}
|
|
509
961
|
};
|
|
962
|
+
/** Index every definition in the corpus by its name (a name may be defined in several files). */
|
|
963
|
+
function indexDefinitions(parsed) {
|
|
964
|
+
const defsByName = /* @__PURE__ */ new Map();
|
|
965
|
+
for (const { parsed: file } of parsed) for (const def of file.definitions) {
|
|
966
|
+
const list = defsByName.get(def.name) ?? [];
|
|
967
|
+
list.push(def);
|
|
968
|
+
defsByName.set(def.name, list);
|
|
969
|
+
}
|
|
970
|
+
return defsByName;
|
|
971
|
+
}
|
|
972
|
+
/** Score each definition by weighted reference count + personalization + mention boost. */
|
|
973
|
+
function rankSymbols(parsed, request) {
|
|
974
|
+
const activeFiles = new Set(request.activeFiles ?? []);
|
|
975
|
+
const mentioned = new Set(request.mentionedIdentifiers ?? []);
|
|
976
|
+
const defsByName = indexDefinitions(parsed);
|
|
977
|
+
const scoreByKey = /* @__PURE__ */ new Map();
|
|
978
|
+
const bump = (s, delta) => {
|
|
979
|
+
scoreByKey.set(symbolKey(s), (scoreByKey.get(symbolKey(s)) ?? 0) + delta);
|
|
980
|
+
};
|
|
981
|
+
for (const { file, parsed: source } of parsed) {
|
|
982
|
+
const weight = activeFiles.has(file) ? ACTIVE_FILE_WEIGHT : 1;
|
|
983
|
+
for (const ref of source.references) for (const def of defsByName.get(ref) ?? []) if (def.file !== file) bump(def, weight);
|
|
984
|
+
}
|
|
985
|
+
for (const name of mentioned) for (const def of defsByName.get(name) ?? []) bump(def, MENTION_BOOST);
|
|
986
|
+
const ranked = [];
|
|
987
|
+
for (const defs of defsByName.values()) for (const def of defs) ranked.push({
|
|
988
|
+
...def,
|
|
989
|
+
score: scoreByKey.get(symbolKey(def)) ?? 0,
|
|
990
|
+
tokens: estimateTokens(def)
|
|
991
|
+
});
|
|
992
|
+
ranked.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name));
|
|
993
|
+
return ranked;
|
|
994
|
+
}
|
|
995
|
+
/** Take the most-relevant-first prefix whose cumulative tokens fit the budget. */
|
|
996
|
+
function selectWithinBudget(ranked, tokenBudget) {
|
|
997
|
+
const symbols = [];
|
|
998
|
+
let totalTokens = 0;
|
|
999
|
+
for (const entry of ranked) {
|
|
1000
|
+
if (totalTokens + entry.tokens > tokenBudget) break;
|
|
1001
|
+
symbols.push(entry);
|
|
1002
|
+
totalTokens += entry.tokens;
|
|
1003
|
+
}
|
|
1004
|
+
return {
|
|
1005
|
+
symbols,
|
|
1006
|
+
totalTokens
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/implementations/function-tool.ts
|
|
510
1011
|
/**
|
|
511
1012
|
* Helper function to create a function tool from a simple function
|
|
512
1013
|
*/
|
|
@@ -520,11 +1021,12 @@ function createFunctionTool(name, description, parameters, fn) {
|
|
|
520
1021
|
/**
|
|
521
1022
|
* Helper function to create a function tool from Zod schema
|
|
522
1023
|
*/
|
|
523
|
-
function createZodFunctionTool(name, description, zodSchema, fn) {
|
|
1024
|
+
function createZodFunctionTool(name, description, zodSchema, fn, residency = {}) {
|
|
524
1025
|
const schema = {
|
|
525
1026
|
name,
|
|
526
1027
|
description,
|
|
527
|
-
parameters: zodToJsonSchema(zodSchema)
|
|
1028
|
+
parameters: zodToJsonSchema(zodSchema),
|
|
1029
|
+
...residency.deferLoading !== void 0 && { deferLoading: residency.deferLoading }
|
|
528
1030
|
};
|
|
529
1031
|
const wrappedFn = async (parameters, context) => {
|
|
530
1032
|
const parseResult = zodSchema.safeParse(parameters);
|
|
@@ -535,6 +1037,460 @@ function createZodFunctionTool(name, description, zodSchema, fn) {
|
|
|
535
1037
|
return new FunctionTool(schema, wrappedFn);
|
|
536
1038
|
}
|
|
537
1039
|
//#endregion
|
|
1040
|
+
//#region src/tool-permission-profiles.ts
|
|
1041
|
+
/**
|
|
1042
|
+
* What the permission system is told about the tools THIS package defines. CORE-030.
|
|
1043
|
+
*
|
|
1044
|
+
* The classification used to live in `@robota-sdk/agent-core`'s `permission-mode.ts`, as a matrix
|
|
1045
|
+
* keyed on a closed union of product tool names — a vendor-neutral foundation holding a product's
|
|
1046
|
+
* tool inventory, two layers below the code that defines it, with nothing coupling the two lists.
|
|
1047
|
+
* They drifted: `CodebaseRetrieval` is defined here and the matrix had never heard of it, so a
|
|
1048
|
+
* read-only retrieval prompted on every call and was refused outright in plan mode.
|
|
1049
|
+
*
|
|
1050
|
+
* A tool's own package declares what it does. The foundation decides what each MODE does about that
|
|
1051
|
+
* kind of action, and neither restates the other's half.
|
|
1052
|
+
*
|
|
1053
|
+
* `packages/agent-tools/src/__tests__/tool-permission-profiles.test.ts` asserts that every tool this
|
|
1054
|
+
* package produces appears here, so adding a tool without classifying it fails rather than silently
|
|
1055
|
+
* inheriting the prompt-on-every-call fallback.
|
|
1056
|
+
*/
|
|
1057
|
+
/**
|
|
1058
|
+
* Every tool this package defines, and what the permission system needs to know about it.
|
|
1059
|
+
*
|
|
1060
|
+
* `argument.key` is which argument a pattern like `Read(/src/**)` is matched against, and
|
|
1061
|
+
* `argument.kind` how (CORE-049: a URL is parsed, a path is segment-wise, a command is a glob). A tool without
|
|
1062
|
+
* one cannot be narrowed by an argument pattern at all — the gate treats such a pattern as
|
|
1063
|
+
* unevaluable and prompts rather than proceeding, which is why the ones that CAN be narrowed say so.
|
|
1064
|
+
*/
|
|
1065
|
+
const AGENT_TOOL_PERMISSION_PROFILES = {
|
|
1066
|
+
Read: {
|
|
1067
|
+
argument: {
|
|
1068
|
+
key: "filePath",
|
|
1069
|
+
kind: "path"
|
|
1070
|
+
},
|
|
1071
|
+
riskClass: "inspect"
|
|
1072
|
+
},
|
|
1073
|
+
Glob: {
|
|
1074
|
+
argument: {
|
|
1075
|
+
key: "pattern",
|
|
1076
|
+
kind: "text"
|
|
1077
|
+
},
|
|
1078
|
+
riskClass: "inspect"
|
|
1079
|
+
},
|
|
1080
|
+
Grep: {
|
|
1081
|
+
argument: {
|
|
1082
|
+
key: "pattern",
|
|
1083
|
+
kind: "text"
|
|
1084
|
+
},
|
|
1085
|
+
riskClass: "inspect"
|
|
1086
|
+
},
|
|
1087
|
+
WebFetch: {
|
|
1088
|
+
argument: {
|
|
1089
|
+
key: "url",
|
|
1090
|
+
kind: "url"
|
|
1091
|
+
},
|
|
1092
|
+
riskClass: "inspect"
|
|
1093
|
+
},
|
|
1094
|
+
WebSearch: {
|
|
1095
|
+
argument: {
|
|
1096
|
+
key: "query",
|
|
1097
|
+
kind: "text"
|
|
1098
|
+
},
|
|
1099
|
+
riskClass: "inspect"
|
|
1100
|
+
},
|
|
1101
|
+
CodebaseRetrieval: { riskClass: "inspect" },
|
|
1102
|
+
AskUserQuestion: { riskClass: "inspect" },
|
|
1103
|
+
ToolSearch: {
|
|
1104
|
+
argument: {
|
|
1105
|
+
key: "query",
|
|
1106
|
+
kind: "text"
|
|
1107
|
+
},
|
|
1108
|
+
riskClass: "inspect"
|
|
1109
|
+
},
|
|
1110
|
+
ComputerView: { riskClass: "inspect" },
|
|
1111
|
+
Write: {
|
|
1112
|
+
argument: {
|
|
1113
|
+
key: "filePath",
|
|
1114
|
+
kind: "path"
|
|
1115
|
+
},
|
|
1116
|
+
riskClass: "modify"
|
|
1117
|
+
},
|
|
1118
|
+
Edit: {
|
|
1119
|
+
argument: {
|
|
1120
|
+
key: "filePath",
|
|
1121
|
+
kind: "path"
|
|
1122
|
+
},
|
|
1123
|
+
riskClass: "modify"
|
|
1124
|
+
},
|
|
1125
|
+
Shell: {
|
|
1126
|
+
argument: {
|
|
1127
|
+
key: "command",
|
|
1128
|
+
kind: "command"
|
|
1129
|
+
},
|
|
1130
|
+
riskClass: "execute",
|
|
1131
|
+
aliases: ["Bash"]
|
|
1132
|
+
},
|
|
1133
|
+
Bash: {
|
|
1134
|
+
argument: {
|
|
1135
|
+
key: "command",
|
|
1136
|
+
kind: "command"
|
|
1137
|
+
},
|
|
1138
|
+
riskClass: "execute",
|
|
1139
|
+
aliases: ["Shell"]
|
|
1140
|
+
},
|
|
1141
|
+
Computer: { riskClass: "execute" }
|
|
1142
|
+
};
|
|
1143
|
+
/**
|
|
1144
|
+
* Tell the permission system about every tool this package defines. Idempotent.
|
|
1145
|
+
*
|
|
1146
|
+
* Not exported: the one caller is the line below. A registration a consumer could choose to skip is
|
|
1147
|
+
* a registration that might not happen, which is the state this change exists to leave behind.
|
|
1148
|
+
*/
|
|
1149
|
+
function registerAgentToolPermissionProfiles() {
|
|
1150
|
+
for (const [toolName, profile] of Object.entries(AGENT_TOOL_PERMISSION_PROFILES)) registerToolPermissionProfile(toolName, profile);
|
|
1151
|
+
}
|
|
1152
|
+
registerAgentToolPermissionProfiles();
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/retrieval/retrieval-tool.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* SELFHOST-003: the `CodebaseRetrieval` tool — mirrors the `create*Tool(options)` pattern.
|
|
1157
|
+
*
|
|
1158
|
+
* Composes over the injected `IRetrievalAdapter` (via `IRetrievalToolOptions`). It carries NO corpus and
|
|
1159
|
+
* NO domain content itself — the adapter (built from a surface-supplied parser + corpus) does the
|
|
1160
|
+
* ranking. With no adapter the tool reports unavailability (it is added to the default set only when an
|
|
1161
|
+
* adapter is present — see `createDefaultTools`).
|
|
1162
|
+
*/
|
|
1163
|
+
/** Default token budget when the caller does not specify one. */
|
|
1164
|
+
const DEFAULT_TOKEN_BUDGET = 1e3;
|
|
1165
|
+
const RetrievalSchema = z.object({
|
|
1166
|
+
activeFiles: z.array(z.string()).optional().describe("Repo-relative files currently in focus; the map is ranked toward what they reference."),
|
|
1167
|
+
mentionedIdentifiers: z.array(z.string()).optional().describe("Symbol names to bias the map toward (e.g. identifiers named in the task)."),
|
|
1168
|
+
tokenBudget: z.number().int().positive().optional().describe(`Maximum tokens for the returned map (default ${DEFAULT_TOKEN_BUDGET}).`)
|
|
1169
|
+
});
|
|
1170
|
+
/** Render the ranked symbols as a compact, deterministic repo map. */
|
|
1171
|
+
function formatRepoMap(symbols) {
|
|
1172
|
+
return symbols.map((symbol) => `${symbol.file}:${symbol.line} ${symbol.kind} ${symbol.name}`).join("\n");
|
|
1173
|
+
}
|
|
1174
|
+
async function retrievalTool(args, options = {}) {
|
|
1175
|
+
if (!options.adapter) return "Codebase retrieval is not available in this session.";
|
|
1176
|
+
const result = await options.adapter.retrieve({
|
|
1177
|
+
...args.activeFiles ? { activeFiles: args.activeFiles } : {},
|
|
1178
|
+
...args.mentionedIdentifiers ? { mentionedIdentifiers: args.mentionedIdentifiers } : {},
|
|
1179
|
+
tokenBudget: args.tokenBudget ?? DEFAULT_TOKEN_BUDGET
|
|
1180
|
+
});
|
|
1181
|
+
if (result.symbols.length === 0) return "No relevant symbols found within the token budget.";
|
|
1182
|
+
return `Most relevant symbols (~${result.totalTokens} tokens):\n${formatRepoMap(result.symbols)}`;
|
|
1183
|
+
}
|
|
1184
|
+
function createRetrievalTool(options = {}) {
|
|
1185
|
+
return createZodFunctionTool("CodebaseRetrieval", "Retrieve the most relevant slice of the codebase (a ranked repo map of symbols) for the current task, within a token budget. Provide the files you are focused on and/or identifiers named in the task; returns the highest-centrality definitions first.", RetrievalSchema, async (params) => retrievalTool(params, options));
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
1188
|
+
//#region src/computer-use/computer-tool.ts
|
|
1189
|
+
/**
|
|
1190
|
+
* SELFHOST-010: the `ComputerView` (perceive) + `Computer` (act) tools — mirror the `create*Tool(options)`
|
|
1191
|
+
* pattern, split along the permission boundary.
|
|
1192
|
+
*
|
|
1193
|
+
* `createComputerTool({ driver })` registers BOTH tool names over one injected `IComputerDriver`. The split
|
|
1194
|
+
* is purely the permission-bearing boundary (the repo's own `Read`(auto)-vs-`Shell`(approve) precedent):
|
|
1195
|
+
* - `ComputerView` calls `driver.screenshot()` — a perceive with no action argument (gated `auto` like `Read`).
|
|
1196
|
+
* - `Computer` takes a single typed mutating `action`, executes it via `driver.act()`, and returns the
|
|
1197
|
+
* resulting screenshot so the model re-perceives (gated `approve`/`deny` like `Shell`).
|
|
1198
|
+
*
|
|
1199
|
+
* The typed action union stays WHOLE in the driver contract (`./types.ts`); this file only maps the
|
|
1200
|
+
* tool-boundary argument onto it. With no driver the tools report unavailability — they are added to the
|
|
1201
|
+
* default set ONLY when a driver is present (adapter-gated; there is NO host fallback — see
|
|
1202
|
+
* `createDefaultTools`).
|
|
1203
|
+
*/
|
|
1204
|
+
const UNAVAILABLE_MESSAGE = "Computer use is not available in this session (no driver injected).";
|
|
1205
|
+
const MouseButtonSchema = z.enum([
|
|
1206
|
+
"left",
|
|
1207
|
+
"right",
|
|
1208
|
+
"middle"
|
|
1209
|
+
]);
|
|
1210
|
+
const PointSchema = z.object({
|
|
1211
|
+
x: z.number(),
|
|
1212
|
+
y: z.number()
|
|
1213
|
+
});
|
|
1214
|
+
/**
|
|
1215
|
+
* The `Computer` action argument. A flat object (not a discriminated union) so it converts to JSON schema
|
|
1216
|
+
* — `type` selects the action and the remaining fields are validated per type in {@link buildAction}. The
|
|
1217
|
+
* strongly-typed discriminated union lives in the driver contract (`TComputerAction`).
|
|
1218
|
+
*/
|
|
1219
|
+
const ActionSchema = z.object({
|
|
1220
|
+
type: z.enum([
|
|
1221
|
+
"click",
|
|
1222
|
+
"double_click",
|
|
1223
|
+
"type",
|
|
1224
|
+
"keypress",
|
|
1225
|
+
"scroll",
|
|
1226
|
+
"drag",
|
|
1227
|
+
"wait",
|
|
1228
|
+
"takeover"
|
|
1229
|
+
]).describe("Which action to perform."),
|
|
1230
|
+
x: z.number().optional().describe("X coordinate (click/double_click/scroll)."),
|
|
1231
|
+
y: z.number().optional().describe("Y coordinate (click/double_click/scroll)."),
|
|
1232
|
+
button: MouseButtonSchema.optional().describe("Mouse button (click/double_click/drag)."),
|
|
1233
|
+
text: z.string().optional().describe("Text to type (type)."),
|
|
1234
|
+
keys: z.array(z.string()).optional().describe("Keys to press as a chord (keypress)."),
|
|
1235
|
+
deltaX: z.number().optional().describe("Horizontal wheel delta (scroll)."),
|
|
1236
|
+
deltaY: z.number().optional().describe("Vertical wheel delta (scroll)."),
|
|
1237
|
+
path: z.array(PointSchema).optional().describe("Points to drag through (drag)."),
|
|
1238
|
+
ms: z.number().optional().describe("Milliseconds to wait (wait)."),
|
|
1239
|
+
reason: z.string().optional().describe("Human-readable reason surfaced to the user (takeover).")
|
|
1240
|
+
});
|
|
1241
|
+
const ComputerSchema = z.object({ action: ActionSchema.describe("The single mutating action to perform.") });
|
|
1242
|
+
const ComputerViewSchema = z.object({});
|
|
1243
|
+
/** Raised when the tool-boundary action argument is missing fields the action type requires. */
|
|
1244
|
+
var InvalidComputerActionError = class extends Error {};
|
|
1245
|
+
function requireNumber(value, field, type) {
|
|
1246
|
+
if (typeof value !== "number") throw new InvalidComputerActionError(`Action '${type}' requires numeric '${field}'.`);
|
|
1247
|
+
return value;
|
|
1248
|
+
}
|
|
1249
|
+
/** Map the flat tool-boundary argument onto the strongly-typed driver action union. */
|
|
1250
|
+
function buildAction(args) {
|
|
1251
|
+
const button = args.button;
|
|
1252
|
+
switch (args.type) {
|
|
1253
|
+
case "click": return {
|
|
1254
|
+
type: "click",
|
|
1255
|
+
x: requireNumber(args.x, "x", "click"),
|
|
1256
|
+
y: requireNumber(args.y, "y", "click"),
|
|
1257
|
+
...button ? { button } : {}
|
|
1258
|
+
};
|
|
1259
|
+
case "double_click": return {
|
|
1260
|
+
type: "double_click",
|
|
1261
|
+
x: requireNumber(args.x, "x", "double_click"),
|
|
1262
|
+
y: requireNumber(args.y, "y", "double_click"),
|
|
1263
|
+
...button ? { button } : {}
|
|
1264
|
+
};
|
|
1265
|
+
case "type":
|
|
1266
|
+
if (typeof args.text !== "string") throw new InvalidComputerActionError("Action 'type' requires 'text'.");
|
|
1267
|
+
return {
|
|
1268
|
+
type: "type",
|
|
1269
|
+
text: args.text
|
|
1270
|
+
};
|
|
1271
|
+
case "keypress":
|
|
1272
|
+
if (!args.keys || args.keys.length === 0) throw new InvalidComputerActionError("Action 'keypress' requires non-empty 'keys'.");
|
|
1273
|
+
return {
|
|
1274
|
+
type: "keypress",
|
|
1275
|
+
keys: args.keys
|
|
1276
|
+
};
|
|
1277
|
+
case "scroll": return {
|
|
1278
|
+
type: "scroll",
|
|
1279
|
+
x: requireNumber(args.x, "x", "scroll"),
|
|
1280
|
+
y: requireNumber(args.y, "y", "scroll"),
|
|
1281
|
+
deltaX: requireNumber(args.deltaX, "deltaX", "scroll"),
|
|
1282
|
+
deltaY: requireNumber(args.deltaY, "deltaY", "scroll")
|
|
1283
|
+
};
|
|
1284
|
+
case "drag":
|
|
1285
|
+
if (!args.path || args.path.length < 2) throw new InvalidComputerActionError("Action 'drag' requires a 'path' of at least two points.");
|
|
1286
|
+
return {
|
|
1287
|
+
type: "drag",
|
|
1288
|
+
path: args.path,
|
|
1289
|
+
...button ? { button } : {}
|
|
1290
|
+
};
|
|
1291
|
+
case "wait": return {
|
|
1292
|
+
type: "wait",
|
|
1293
|
+
...typeof args.ms === "number" ? { ms: args.ms } : {}
|
|
1294
|
+
};
|
|
1295
|
+
case "takeover": return {
|
|
1296
|
+
type: "takeover",
|
|
1297
|
+
...args.reason ? { reason: args.reason } : {}
|
|
1298
|
+
};
|
|
1299
|
+
default: {
|
|
1300
|
+
const exhaustive = args.type;
|
|
1301
|
+
throw new InvalidComputerActionError(`Unknown action type: ${String(exhaustive)}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** `ComputerView` — perceive the current surface (returns a screenshot). Gated `auto` like `Read`. */
|
|
1306
|
+
async function perceive(options) {
|
|
1307
|
+
if (!options.driver) return JSON.stringify({
|
|
1308
|
+
success: false,
|
|
1309
|
+
error: UNAVAILABLE_MESSAGE
|
|
1310
|
+
});
|
|
1311
|
+
const screenshot = await options.driver.screenshot();
|
|
1312
|
+
return JSON.stringify(screenshot ? {
|
|
1313
|
+
success: true,
|
|
1314
|
+
screenshot
|
|
1315
|
+
} : {
|
|
1316
|
+
success: true,
|
|
1317
|
+
takeover: true
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
/** `Computer` — execute one typed mutating action and return the resulting screenshot. Gated like `Shell`. */
|
|
1321
|
+
async function act(args, options) {
|
|
1322
|
+
if (!options.driver) return JSON.stringify({
|
|
1323
|
+
success: false,
|
|
1324
|
+
error: UNAVAILABLE_MESSAGE
|
|
1325
|
+
});
|
|
1326
|
+
let action;
|
|
1327
|
+
try {
|
|
1328
|
+
action = buildAction(args.action);
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
return JSON.stringify({
|
|
1331
|
+
success: false,
|
|
1332
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
const outcome = await options.driver.act(action);
|
|
1336
|
+
const result = {
|
|
1337
|
+
success: true,
|
|
1338
|
+
...outcome.screenshot ? { screenshot: outcome.screenshot } : {},
|
|
1339
|
+
...outcome.takeover ? { takeover: true } : {}
|
|
1340
|
+
};
|
|
1341
|
+
return JSON.stringify(result);
|
|
1342
|
+
}
|
|
1343
|
+
/** Build the `ComputerView` perceive tool over the injected driver. */
|
|
1344
|
+
function createComputerViewTool(options = {}) {
|
|
1345
|
+
return createZodFunctionTool("ComputerView", "Perceive the computer/browser surface: capture and return a screenshot of the current screen so you can reason about what to do next. Read-only — it never changes anything.", ComputerViewSchema, async () => perceive(options));
|
|
1346
|
+
}
|
|
1347
|
+
/** Build the `Computer` act tool over the injected driver. */
|
|
1348
|
+
function createComputerActTool(options = {}) {
|
|
1349
|
+
return createZodFunctionTool("Computer", "Perform one mutating action on the computer/browser surface (click, double_click, type, keypress, scroll, drag, wait, or takeover) and return the resulting screenshot. Use `takeover` to hand control to the human for sensitive input (credentials/payment); perception is paused during a takeover.", ComputerSchema, async (params) => act(params, options));
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Create BOTH computer-use tools — `ComputerView` (perceive) and `Computer` (act) — over one injected
|
|
1353
|
+
* driver. Mirrors `create*Tool(options)`; returns the pair so the assembly layer can spread them into the
|
|
1354
|
+
* default set adapter-gated (see `createDefaultTools`).
|
|
1355
|
+
*/
|
|
1356
|
+
function createComputerTool(options = {}) {
|
|
1357
|
+
return [createComputerViewTool(options), createComputerActTool(options)];
|
|
1358
|
+
}
|
|
1359
|
+
//#endregion
|
|
1360
|
+
//#region src/computer-use/page-computer-driver.ts
|
|
1361
|
+
const DEFAULT_MEDIA_TYPE = "image/png";
|
|
1362
|
+
const DEFAULT_WAIT_MS = 500;
|
|
1363
|
+
/** Encode raw screenshot bytes to base64 (accepts the page's `Uint8Array` or an already-encoded string). */
|
|
1364
|
+
function encodeScreenshot(bytes) {
|
|
1365
|
+
if (typeof bytes === "string") return bytes;
|
|
1366
|
+
return Buffer.from(bytes).toString("base64");
|
|
1367
|
+
}
|
|
1368
|
+
var PageComputerDriver = class {
|
|
1369
|
+
page;
|
|
1370
|
+
mediaType;
|
|
1371
|
+
defaultWaitMs;
|
|
1372
|
+
suspended = false;
|
|
1373
|
+
constructor(options) {
|
|
1374
|
+
this.page = options.page;
|
|
1375
|
+
this.mediaType = options.mediaType ?? DEFAULT_MEDIA_TYPE;
|
|
1376
|
+
this.defaultWaitMs = options.defaultWaitMs ?? DEFAULT_WAIT_MS;
|
|
1377
|
+
}
|
|
1378
|
+
async capture() {
|
|
1379
|
+
const type = this.mediaType === "image/jpeg" ? "jpeg" : "png";
|
|
1380
|
+
return {
|
|
1381
|
+
data: encodeScreenshot(await this.page.screenshot({ type })),
|
|
1382
|
+
mediaType: this.mediaType
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
async wait(ms) {
|
|
1386
|
+
if (this.page.waitForTimeout) {
|
|
1387
|
+
await this.page.waitForTimeout(ms);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1391
|
+
}
|
|
1392
|
+
async screenshot() {
|
|
1393
|
+
if (this.suspended) return;
|
|
1394
|
+
return this.capture();
|
|
1395
|
+
}
|
|
1396
|
+
async act(action) {
|
|
1397
|
+
if (action.type === "takeover") {
|
|
1398
|
+
await this.beginTakeover(action.reason);
|
|
1399
|
+
return { takeover: true };
|
|
1400
|
+
}
|
|
1401
|
+
if (this.suspended) return { takeover: true };
|
|
1402
|
+
const { mouse, keyboard } = this.page;
|
|
1403
|
+
switch (action.type) {
|
|
1404
|
+
case "click":
|
|
1405
|
+
await mouse.click(action.x, action.y, action.button ? { button: action.button } : void 0);
|
|
1406
|
+
break;
|
|
1407
|
+
case "double_click":
|
|
1408
|
+
await mouse.click(action.x, action.y, {
|
|
1409
|
+
clickCount: 2,
|
|
1410
|
+
...action.button ? { button: action.button } : {}
|
|
1411
|
+
});
|
|
1412
|
+
break;
|
|
1413
|
+
case "type":
|
|
1414
|
+
await keyboard.type(action.text);
|
|
1415
|
+
break;
|
|
1416
|
+
case "keypress":
|
|
1417
|
+
await keyboard.press(action.keys.join("+"));
|
|
1418
|
+
break;
|
|
1419
|
+
case "scroll":
|
|
1420
|
+
await mouse.move(action.x, action.y);
|
|
1421
|
+
await mouse.wheel(action.deltaX, action.deltaY);
|
|
1422
|
+
break;
|
|
1423
|
+
case "drag":
|
|
1424
|
+
await this.performDrag(action);
|
|
1425
|
+
break;
|
|
1426
|
+
case "wait":
|
|
1427
|
+
await this.wait(action.ms ?? this.defaultWaitMs);
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
return { screenshot: await this.capture() };
|
|
1431
|
+
}
|
|
1432
|
+
/** Move the pointer along a multi-point path with the button held (mouse down → moves → up). */
|
|
1433
|
+
async performDrag(action) {
|
|
1434
|
+
if (action.path.length < 2) throw new Error("computer drag requires a path of at least 2 points (start + end)");
|
|
1435
|
+
const { mouse } = this.page;
|
|
1436
|
+
const [first, ...rest] = action.path;
|
|
1437
|
+
const button = action.button ? { button: action.button } : void 0;
|
|
1438
|
+
await mouse.move(first.x, first.y);
|
|
1439
|
+
await mouse.down(button);
|
|
1440
|
+
for (const point of rest) await mouse.move(point.x, point.y);
|
|
1441
|
+
await mouse.up(button);
|
|
1442
|
+
}
|
|
1443
|
+
async beginTakeover(_reason) {
|
|
1444
|
+
this.suspended = true;
|
|
1445
|
+
}
|
|
1446
|
+
async endTakeover() {
|
|
1447
|
+
this.suspended = false;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
//#endregion
|
|
1451
|
+
//#region src/builtins/shell-tool-description.ts
|
|
1452
|
+
/**
|
|
1453
|
+
* Dedicated-tool routing hints, keyed by the sibling tool's registered name. A hint is only
|
|
1454
|
+
* emitted when that sibling is actually part of the registered tool set (NEUT-002) — the
|
|
1455
|
+
* description must not route the model to tools that do not exist in a given assembly.
|
|
1456
|
+
*/
|
|
1457
|
+
const SIBLING_ROUTING_HINTS = [
|
|
1458
|
+
{
|
|
1459
|
+
toolName: "Glob",
|
|
1460
|
+
hint: " - File search: Use Glob (NOT find or ls)"
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
toolName: "Grep",
|
|
1464
|
+
hint: " - Content search: Use Grep (NOT grep or rg)"
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
toolName: "Read",
|
|
1468
|
+
hint: " - Read files: Use Read (NOT cat/head/tail)"
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
toolName: "Edit",
|
|
1472
|
+
hint: " - Edit files: Use Edit (NOT sed/awk)"
|
|
1473
|
+
}
|
|
1474
|
+
];
|
|
1475
|
+
/**
|
|
1476
|
+
* Build the OS-aware tool description so the model writes syntax the host shell can run.
|
|
1477
|
+
* When `availableTools` is provided, sibling routing hints are restricted to tools in that set;
|
|
1478
|
+
* when omitted, the full default hint set is included (default assembly registers all siblings).
|
|
1479
|
+
*/
|
|
1480
|
+
function buildShellToolDescription(shell, availableTools) {
|
|
1481
|
+
const hints = availableTools ? SIBLING_ROUTING_HINTS.filter((entry) => availableTools.includes(entry.toolName)) : SIBLING_ROUTING_HINTS;
|
|
1482
|
+
const routingBlock = hints.length > 0 ? [`IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands. Instead, use the appropriate dedicated tool:`, ...hints.map((entry) => entry.hint)] : [];
|
|
1483
|
+
return [
|
|
1484
|
+
`Executes a command in the host shell and returns its output.`,
|
|
1485
|
+
``,
|
|
1486
|
+
`Active shell: ${shell.label}. ${shell.syntaxHint}`,
|
|
1487
|
+
``,
|
|
1488
|
+
`Each command runs in a fresh shell in workingDirectory (default: the configured working directory); no shell state carries over between calls.`,
|
|
1489
|
+
``,
|
|
1490
|
+
...routingBlock
|
|
1491
|
+
].join("\n");
|
|
1492
|
+
}
|
|
1493
|
+
//#endregion
|
|
538
1494
|
//#region src/builtins/shell-tool.ts
|
|
539
1495
|
/**
|
|
540
1496
|
* ShellTool — execute a host shell command via child_process.spawn (TERM-008).
|
|
@@ -545,35 +1501,36 @@ function createZodFunctionTool(name, description, zodSchema, fn) {
|
|
|
545
1501
|
*
|
|
546
1502
|
* Returns an IToolInvocationResult JSON string. A non-zero exit is returned as success:true with
|
|
547
1503
|
* exitCode set (the command ran, it just exited non-zero — the LLM decides what to do with that).
|
|
1504
|
+
*
|
|
1505
|
+
* ## SEC-007 — why `workingDirectory` is NOT path-contained (a deliberate decision, not an omission)
|
|
1506
|
+
*
|
|
1507
|
+
* `Read`/`Write`/`Edit` are contained by `checkPathWithinCwd`, and SEC-007 extended that to `Glob`
|
|
1508
|
+
* and `Grep`. This tool is deliberately excluded, and the reason is what the tool IS: it runs an
|
|
1509
|
+
* arbitrary command in a shell. A guard on `cwd` is undone by the first `cd ..` — or by an absolute
|
|
1510
|
+
* path in the command itself — so it would constrain nothing an attacker-controlled command cannot
|
|
1511
|
+
* trivially step around, while LOOKING like a boundary in the code and in review.
|
|
1512
|
+
*
|
|
1513
|
+
* That appearance is the actual hazard. SEC-006's R9 lesson was "'the guard is still there' is not a
|
|
1514
|
+
* verdict": a check that reads as containment but is not one is worse than no check, because the next
|
|
1515
|
+
* reviewer stops asking. The real boundary for this tool is the permission layer (every invocation is
|
|
1516
|
+
* permission-gated at call time) and the sandbox seam below — which is why SEC-006 already recorded
|
|
1517
|
+
* `js/indirect-command-line-injection` at the spawn site as a false positive on those same grounds.
|
|
1518
|
+
*
|
|
1519
|
+
* What the containment root DOES do here: it supplies the DEFAULT working directory. Binding a tool
|
|
1520
|
+
* to a session root and then silently running its commands in `process.cwd()` was a real defect — an
|
|
1521
|
+
* assembly that scoped its file tools to a workspace still ran `Shell` wherever the host process
|
|
1522
|
+
* happened to be started.
|
|
548
1523
|
*/
|
|
549
1524
|
/** POSIX children are spawned detached so a process-group kill reaps grandchildren (CORE-023). */
|
|
550
1525
|
const SPAWN_DETACHED = process.platform !== "win32";
|
|
551
1526
|
const DEFAULT_TIMEOUT_MS$2 = 12e4;
|
|
1527
|
+
/** ARCH-056: most bytes retained per stream while the child runs (head); the rest is dropped. */
|
|
1528
|
+
const MAX_CAPTURED_OUTPUT_BYTES = 2e6;
|
|
552
1529
|
const ShellSchema = z.object({
|
|
553
1530
|
command: z.string().describe("The shell command to execute"),
|
|
554
1531
|
timeout: z.number().optional().describe("Optional timeout in milliseconds (max 600000). Default is 120000 (2 minutes)"),
|
|
555
1532
|
workingDirectory: z.string().optional().describe("Working directory for the command. Defaults to the current working directory")
|
|
556
1533
|
});
|
|
557
|
-
/** Build the OS-aware tool description so the model writes syntax the host shell can run. */
|
|
558
|
-
function buildShellToolDescription(shell) {
|
|
559
|
-
return [
|
|
560
|
-
`Executes a command in the host shell and returns its output.`,
|
|
561
|
-
``,
|
|
562
|
-
`Active shell: ${shell.label}. ${shell.syntaxHint}`,
|
|
563
|
-
``,
|
|
564
|
-
`The working directory persists between commands, but shell state does not.`,
|
|
565
|
-
``,
|
|
566
|
-
`IMPORTANT: Avoid using this tool to run \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands. Instead, use the appropriate dedicated tool:`,
|
|
567
|
-
` - File search: Use Glob (NOT find or ls)`,
|
|
568
|
-
` - Content search: Use Grep (NOT grep or rg)`,
|
|
569
|
-
` - Read files: Use Read (NOT cat/head/tail)`,
|
|
570
|
-
` - Edit files: Use Edit (NOT sed/awk)`,
|
|
571
|
-
``,
|
|
572
|
-
`For simple commands, keep the description brief (5-10 words). For complex commands, include enough context to clarify what the command does.`,
|
|
573
|
-
``,
|
|
574
|
-
`Output is limited to 30,000 characters. Longer output will be middle-truncated.`
|
|
575
|
-
].join("\n");
|
|
576
|
-
}
|
|
577
1534
|
/** Run a shell command through the sandbox client, surfacing failures as a structured result. */
|
|
578
1535
|
async function runInSandbox(command, timeout, workingDirectory, options) {
|
|
579
1536
|
try {
|
|
@@ -600,44 +1557,86 @@ async function runInSandbox(command, timeout, workingDirectory, options) {
|
|
|
600
1557
|
* Run a shell command and return stdout + stderr.
|
|
601
1558
|
* Resolves with the IToolInvocationResult JSON string.
|
|
602
1559
|
*/
|
|
603
|
-
async function runShell(args, options
|
|
1560
|
+
async function runShell(args, options, shell, signal, traceEnv) {
|
|
604
1561
|
const { command, timeout: rawTimeout = DEFAULT_TIMEOUT_MS$2, workingDirectory } = args;
|
|
605
1562
|
const timeout = Math.min(rawTimeout, 6e5);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (signal?.aborted) return JSON.stringify({
|
|
1563
|
+
const effectiveCwd = workingDirectory ?? options.cwd;
|
|
1564
|
+
if (effectiveCwd === void 0) return JSON.stringify({
|
|
609
1565
|
success: false,
|
|
610
1566
|
output: "",
|
|
611
|
-
error: "
|
|
1567
|
+
error: "Shell tool has no working directory: it was constructed without a `cwd` (ARCH-010). This is an assembly bug — the tool would otherwise run in whatever directory the host process was started in."
|
|
612
1568
|
});
|
|
1569
|
+
if (options.sandboxClient && options.sandboxClient.wrapCommand === void 0) return runInSandbox(command, timeout, workingDirectory ?? options.cwd, options);
|
|
1570
|
+
const hostInvocation = {
|
|
1571
|
+
command: shell.command,
|
|
1572
|
+
args: shell.commandArgs(command),
|
|
1573
|
+
cwd: effectiveCwd
|
|
1574
|
+
};
|
|
1575
|
+
const invocation = options.sandboxClient?.wrapCommand?.(hostInvocation, command) ?? hostInvocation;
|
|
1576
|
+
let released = false;
|
|
1577
|
+
const release = () => {
|
|
1578
|
+
if (released) return void 0;
|
|
1579
|
+
released = true;
|
|
1580
|
+
try {
|
|
1581
|
+
return invocation.afterExit?.();
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
return `[sandbox] clean-up failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
if (signal?.aborted) {
|
|
1587
|
+
release();
|
|
1588
|
+
return JSON.stringify({
|
|
1589
|
+
success: false,
|
|
1590
|
+
output: "",
|
|
1591
|
+
error: "Aborted before start"
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
613
1594
|
return new Promise((resolve) => {
|
|
614
|
-
const
|
|
615
|
-
const
|
|
1595
|
+
const stdoutOutput = createBoundedOutput({ maxBytes: MAX_CAPTURED_OUTPUT_BYTES });
|
|
1596
|
+
const stderrOutput = createBoundedOutput({ maxBytes: MAX_CAPTURED_OUTPUT_BYTES });
|
|
616
1597
|
let timedOut = false;
|
|
617
1598
|
let settled = false;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1599
|
+
let child;
|
|
1600
|
+
try {
|
|
1601
|
+
child = spawn(invocation.command, [...invocation.args], {
|
|
1602
|
+
cwd: invocation.cwd,
|
|
1603
|
+
env: traceEnv === void 0 ? process.env : subprocessTraceEnvironment(process.env, traceEnv),
|
|
1604
|
+
stdio: [
|
|
1605
|
+
"pipe",
|
|
1606
|
+
"pipe",
|
|
1607
|
+
"pipe",
|
|
1608
|
+
...(invocation.inputDescriptors ?? []).map(() => "pipe")
|
|
1609
|
+
],
|
|
1610
|
+
detached: SPAWN_DETACHED
|
|
1611
|
+
});
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
const note = release();
|
|
1614
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1615
|
+
resolve(JSON.stringify({
|
|
1616
|
+
success: false,
|
|
1617
|
+
output: note ?? "",
|
|
1618
|
+
error: message
|
|
1619
|
+
}));
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
(invocation.inputDescriptors ?? []).forEach((data, index) => {
|
|
1623
|
+
const stream = child.stdio[index + 3];
|
|
1624
|
+
stream?.on("error", () => void 0);
|
|
1625
|
+
stream?.end(Buffer.from(data));
|
|
627
1626
|
});
|
|
628
1627
|
child.stdin?.end();
|
|
629
|
-
child.stdout
|
|
630
|
-
|
|
1628
|
+
child.stdout?.on("data", (chunk) => {
|
|
1629
|
+
stdoutOutput.append(chunk);
|
|
631
1630
|
});
|
|
632
|
-
child.stderr
|
|
633
|
-
|
|
1631
|
+
child.stderr?.on("data", (chunk) => {
|
|
1632
|
+
stderrOutput.append(chunk);
|
|
634
1633
|
});
|
|
635
1634
|
const timer = setTimeout(() => {
|
|
636
1635
|
timedOut = true;
|
|
637
1636
|
killProcessTree(child, { processGroup: SPAWN_DETACHED });
|
|
638
1637
|
settle({
|
|
639
1638
|
success: false,
|
|
640
|
-
output:
|
|
1639
|
+
output: stdoutOutput.toString(),
|
|
641
1640
|
error: `Command timed out after ${timeout}ms`
|
|
642
1641
|
});
|
|
643
1642
|
}, timeout);
|
|
@@ -652,12 +1651,13 @@ async function runShell(args, options = {}, signal) {
|
|
|
652
1651
|
killProcessTree(child, { processGroup: SPAWN_DETACHED });
|
|
653
1652
|
settle({
|
|
654
1653
|
success: false,
|
|
655
|
-
output:
|
|
1654
|
+
output: stdoutOutput.toString(),
|
|
656
1655
|
error: "Aborted"
|
|
657
1656
|
});
|
|
658
1657
|
}
|
|
659
1658
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
660
1659
|
child.on("error", (err) => {
|
|
1660
|
+
if (child.pid === void 0) release();
|
|
661
1661
|
settle({
|
|
662
1662
|
success: false,
|
|
663
1663
|
output: "",
|
|
@@ -665,21 +1665,23 @@ async function runShell(args, options = {}, signal) {
|
|
|
665
1665
|
});
|
|
666
1666
|
});
|
|
667
1667
|
child.on("close", (code) => {
|
|
1668
|
+
const note = release();
|
|
668
1669
|
if (timedOut) {
|
|
669
1670
|
settle({
|
|
670
1671
|
success: false,
|
|
671
|
-
output:
|
|
1672
|
+
output: stdoutOutput.toString(),
|
|
672
1673
|
error: `Command timed out after ${timeout}ms`,
|
|
673
1674
|
exitCode: code ?? void 0
|
|
674
1675
|
});
|
|
675
1676
|
return;
|
|
676
1677
|
}
|
|
677
|
-
const stdout =
|
|
678
|
-
const stderr =
|
|
1678
|
+
const stdout = stdoutOutput.toString();
|
|
1679
|
+
const stderr = stderrOutput.toString();
|
|
679
1680
|
const exitCode = code ?? 0;
|
|
1681
|
+
const combined = stderr ? `${stdout}\nstderr:\n${stderr}` : stdout;
|
|
680
1682
|
settle({
|
|
681
1683
|
success: true,
|
|
682
|
-
output:
|
|
1684
|
+
output: note === void 0 ? combined : `${combined}\n${note}`,
|
|
683
1685
|
exitCode
|
|
684
1686
|
});
|
|
685
1687
|
});
|
|
@@ -692,38 +1694,96 @@ async function runShell(args, options = {}, signal) {
|
|
|
692
1694
|
* model writes the right syntax regardless of which alias it calls.
|
|
693
1695
|
*/
|
|
694
1696
|
function createHostShellTool(name, options) {
|
|
695
|
-
|
|
696
|
-
|
|
1697
|
+
const shell = resolvePlatformShell({ executable: options.shellExecutable });
|
|
1698
|
+
return createZodFunctionTool(name, options.description ?? buildShellToolDescription(shell, options.availableTools), ShellSchema, async (params, context) => {
|
|
1699
|
+
return runShell(params, options, shell, context?.signal, context?.shellTraceEnv);
|
|
697
1700
|
});
|
|
698
1701
|
}
|
|
699
1702
|
/**
|
|
700
1703
|
* Create a `Shell` tool instance — register with the Robota agent tools registry.
|
|
701
1704
|
* The description is resolved at creation time for the host's active shell.
|
|
702
1705
|
*/
|
|
703
|
-
function createShellTool(options
|
|
1706
|
+
function createShellTool(options) {
|
|
704
1707
|
return createHostShellTool("Shell", options);
|
|
705
1708
|
}
|
|
706
1709
|
/**
|
|
707
1710
|
* Create a `Bash` tool instance — the model-familiar alias of the same OS-aware shell tool.
|
|
708
1711
|
*/
|
|
709
|
-
function createBashTool(options
|
|
1712
|
+
function createBashTool(options) {
|
|
710
1713
|
return createHostShellTool("Bash", options);
|
|
711
1714
|
}
|
|
712
|
-
/** `Shell` tool instance — register with the Robota agent tools registry. */
|
|
713
|
-
const shellTool = createShellTool();
|
|
714
|
-
/** `Bash` tool instance — model-familiar alias of {@link shellTool}. */
|
|
715
|
-
const bashTool = createBashTool();
|
|
716
1715
|
//#endregion
|
|
717
1716
|
//#region src/builtins/path-guard.ts
|
|
718
1717
|
/**
|
|
719
|
-
* Returns a JSON-serialized IToolInvocationResult error when filePath is outside cwd
|
|
720
|
-
* Returns undefined when the path is
|
|
1718
|
+
* Returns a JSON-serialized IToolInvocationResult error when filePath is outside cwd, or when NO
|
|
1719
|
+
* containment root is configured. Returns undefined only when the path is inside a configured root.
|
|
1720
|
+
*
|
|
1721
|
+
* This sentence used to end "or cwd is not set" — the fail-open default ARCH-010 removed. It sat
|
|
1722
|
+
* directly above the two functions that implement the distinction, which is the worst place for a
|
|
1723
|
+
* comment to say the opposite of the code.
|
|
1724
|
+
*
|
|
1725
|
+
* SEC-006: containment is decided on the CANONICAL (symlink-resolved) paths, via the shared
|
|
1726
|
+
* `isPathInside` SSOT in agent-core. A purely lexical `resolve()` + `startsWith` comparison let
|
|
1727
|
+
* `<cwd>/link/secret` through when `link -> /etc`, because `resolve` does not consult the filesystem
|
|
1728
|
+
* and so cannot see a symlink — while the subsequent `readFile`/`writeFile` followed the link out of
|
|
1729
|
+
* the sandbox. For `Write`/`Edit` that meant creating files anywhere the process could reach, and
|
|
1730
|
+
* since symlinks are ordinary committed git content, pointing the agent at an untrusted clone was
|
|
1731
|
+
* enough to arm it.
|
|
1732
|
+
*
|
|
1733
|
+
* The same defect existed in the CLI's monitor asset server; both now share one implementation,
|
|
1734
|
+
* because two containment checks that can disagree are their own defect.
|
|
721
1735
|
*/
|
|
1736
|
+
/**
|
|
1737
|
+
* Whether a host path is inside the tool's containment root — the single predicate every builtin
|
|
1738
|
+
* asks, whatever it does with the answer.
|
|
1739
|
+
*
|
|
1740
|
+
* `checkPathWithinCwd` turns a `false` into the tool-result error a tool RETURNS; the enumerating
|
|
1741
|
+
* tools (`Glob`, `Grep`) instead SKIP the entry mid-walk and must not fabricate an error per file.
|
|
1742
|
+
* Both ask this one question, which asks agent-core's `isPathInside` SSOT — so there is no second
|
|
1743
|
+
* containment rule that could disagree with the first (SEC-006's stated defect, SEC-007 keeping it
|
|
1744
|
+
* true as the guard's reach widens).
|
|
1745
|
+
*
|
|
1746
|
+
* `cwd === undefined` means no containment root is configured, and the answer is NO — ARCH-010.
|
|
1747
|
+
*
|
|
1748
|
+
* This used to return `true` there: with no root, everything was inside it. A guard whose default is
|
|
1749
|
+
* "allow" is not a guard, it is a guard that has to be remembered, and the architecture audit found
|
|
1750
|
+
* three independent layers that had forgotten. `pack-coding` had already written the consequence into
|
|
1751
|
+
* its own source — "file tools constructed with no options carry a DISARMED working-directory guard:
|
|
1752
|
+
* their `Read` will happily return `/etc/hostname`" — and the child-process subagent worker called
|
|
1753
|
+
* `createDefaultTools()` with no argument, so a subagent got exactly that. Measured, not inferred:
|
|
1754
|
+
* before this change a rootless `Read` of `/etc/hostname` returned the file.
|
|
1755
|
+
*
|
|
1756
|
+
* Refusing instead means a construction site that forgets the root fails loudly on its first file
|
|
1757
|
+
* access rather than silently running unconfined. The root is also required by the tool factories now,
|
|
1758
|
+
* so reaching this branch at all is an assembly bug — which is why the error says so specifically
|
|
1759
|
+
* rather than reporting an ordinary out-of-root path.
|
|
1760
|
+
*/
|
|
1761
|
+
function isWithinCwd(filePath, cwd) {
|
|
1762
|
+
if (cwd === void 0) return false;
|
|
1763
|
+
return isPathInside(cwd, filePath);
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Where a RELATIVE host path the model supplied is anchored: the containment root, never
|
|
1767
|
+
* `process.cwd()` (issue #2429). `Read`/`Write`/`Edit` declare `filePath` absolute, but nothing
|
|
1768
|
+
* makes the model comply, and `isPathInside` canonicalises a relative candidate against the PROCESS
|
|
1769
|
+
* directory — so a relative path was confined to one root and judged against another. Same rule as
|
|
1770
|
+
* `resolveSearchRoot` for the enumerating tools. With no root there is nothing to anchor to; the path
|
|
1771
|
+
* is returned as written and `checkPathWithinCwd` refuses it (ARCH-010).
|
|
1772
|
+
*/
|
|
1773
|
+
function resolveHostPath(filePath, cwd) {
|
|
1774
|
+
if (cwd === void 0) return filePath;
|
|
1775
|
+
return resolve(cwd, filePath);
|
|
1776
|
+
}
|
|
722
1777
|
function checkPathWithinCwd(filePath, cwd) {
|
|
723
|
-
if (cwd === void 0)
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1778
|
+
if (cwd === void 0) {
|
|
1779
|
+
const result = {
|
|
1780
|
+
success: false,
|
|
1781
|
+
output: "",
|
|
1782
|
+
error: `Access denied: "${filePath}" cannot be checked because no containment root is configured for this tool. This is an assembly bug, not a path problem — the tool was constructed without a \`cwd\`, so it has no boundary to enforce (ARCH-010).`
|
|
1783
|
+
};
|
|
1784
|
+
return JSON.stringify(result);
|
|
1785
|
+
}
|
|
1786
|
+
if (!isWithinCwd(filePath, cwd)) {
|
|
727
1787
|
const result = {
|
|
728
1788
|
success: false,
|
|
729
1789
|
output: "",
|
|
@@ -732,6 +1792,28 @@ function checkPathWithinCwd(filePath, cwd) {
|
|
|
732
1792
|
return JSON.stringify(result);
|
|
733
1793
|
}
|
|
734
1794
|
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Resolve an LLM-supplied search root for an ENUMERATING tool, and refuse one that escapes (SEC-007).
|
|
1797
|
+
*
|
|
1798
|
+
* A relative `requested` anchors to the CONTAINMENT ROOT, not to `process.cwd()`: anchoring them to
|
|
1799
|
+
* two different directories is how a "contained" search silently starts somewhere else. `error`
|
|
1800
|
+
* carries the tool-result JSON to return, or is `undefined` when the root is allowed.
|
|
1801
|
+
*
|
|
1802
|
+
* With no root there is nothing to anchor to, so this refuses rather than reaching for the process
|
|
1803
|
+
* directory (ARCH-010). The previous `cwd ?? process.cwd()` was that reach: harmless once the guard
|
|
1804
|
+
* below refuses anyway, but it read as a supported fallback, which is the pattern being removed.
|
|
1805
|
+
*/
|
|
1806
|
+
function resolveSearchRoot(requested, cwd) {
|
|
1807
|
+
if (cwd === void 0) return {
|
|
1808
|
+
root: "",
|
|
1809
|
+
error: checkPathWithinCwd(requested ?? "", void 0)
|
|
1810
|
+
};
|
|
1811
|
+
const root = requested ? resolve(cwd, requested) : cwd;
|
|
1812
|
+
return {
|
|
1813
|
+
root,
|
|
1814
|
+
error: checkPathWithinCwd(root, cwd)
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
735
1817
|
//#endregion
|
|
736
1818
|
//#region src/builtins/read-tool.ts
|
|
737
1819
|
/**
|
|
@@ -740,7 +1822,24 @@ function checkPathWithinCwd(filePath, cwd) {
|
|
|
740
1822
|
* Supports offset/limit for partial reads. Detects binary files and refuses to
|
|
741
1823
|
* return their raw bytes. Default limit is 2000 lines.
|
|
742
1824
|
*/
|
|
1825
|
+
const DEFAULT_READ_DESCRIPTION = "Reads a file from the local filesystem.\n\nBy default, reads up to 2000 lines from the beginning of the file. You can optionally specify offset and limit for partial reads.\n\nResults are returned using cat -n format, with line numbers starting at 1.\n\nThe filePath parameter must be an absolute path, not a relative path.";
|
|
743
1826
|
const DEFAULT_LIMIT$1 = 2e3;
|
|
1827
|
+
const MAX_READ_BYTES = 4 * 1024 * 1024;
|
|
1828
|
+
const READ_CHUNK_BYTES$2 = 64 * 1024;
|
|
1829
|
+
/** A budget refusal is a hard failure so a workflow cannot treat it as file content. */
|
|
1830
|
+
var ReadByteLimitError = class extends ToolExecutionError {
|
|
1831
|
+
boundary;
|
|
1832
|
+
constructor(boundary) {
|
|
1833
|
+
super(`Read ${boundary} exceeds its UTF-8 byte limit`, "Read");
|
|
1834
|
+
this.boundary = boundary;
|
|
1835
|
+
}
|
|
1836
|
+
};
|
|
1837
|
+
/** Abort is a hard failure; the workflow must not accept a partial read. */
|
|
1838
|
+
var ReadCancelledError = class extends ToolExecutionError {
|
|
1839
|
+
constructor() {
|
|
1840
|
+
super("Read cancelled", "Read");
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
744
1843
|
const ReadSchema = z.object({
|
|
745
1844
|
filePath: z.string().describe("The absolute path to the file to read"),
|
|
746
1845
|
offset: z.number().optional().describe("The line number to start reading from (1-based). Only provide if the file is too large to read at once"),
|
|
@@ -766,25 +1865,49 @@ function formatWithLineNumbers(lines, startLine) {
|
|
|
766
1865
|
}).join("\n");
|
|
767
1866
|
}
|
|
768
1867
|
function formatReadResult(filePath, content, startLine, limit) {
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
const
|
|
1868
|
+
const selectedLines = [];
|
|
1869
|
+
let selectedMinimumBytes = 0;
|
|
1870
|
+
let totalLines = 0;
|
|
1871
|
+
let lineStart = 0;
|
|
1872
|
+
const selectedStart = Math.trunc(startLine - 1);
|
|
1873
|
+
const selectedEnd = Math.trunc(startLine - 1 + limit);
|
|
1874
|
+
while (lineStart < content.length) {
|
|
1875
|
+
const newline = content.indexOf("\n", lineStart);
|
|
1876
|
+
const lineEnd = newline === -1 ? content.length : newline;
|
|
1877
|
+
totalLines++;
|
|
1878
|
+
if (totalLines > selectedStart && totalLines <= selectedEnd) {
|
|
1879
|
+
const line = content.slice(lineStart, lineEnd);
|
|
1880
|
+
selectedMinimumBytes += Buffer.byteLength(line, "utf8") + String(startLine + selectedLines.length).length + 1;
|
|
1881
|
+
if (selectedMinimumBytes > MAX_READ_BYTES) throw new ReadByteLimitError("output");
|
|
1882
|
+
selectedLines.push(line);
|
|
1883
|
+
}
|
|
1884
|
+
if (newline === -1) break;
|
|
1885
|
+
lineStart = newline + 1;
|
|
1886
|
+
}
|
|
775
1887
|
const returnedLines = selectedLines.length;
|
|
1888
|
+
const header = returnedLines < totalLines ? `[File: ${filePath} (lines ${startLine}-${startLine + returnedLines - 1} of ${totalLines})]\n` : `[File: ${filePath} (${totalLines} lines)]\n`;
|
|
1889
|
+
const width = String(startLine + returnedLines - 1).length;
|
|
1890
|
+
let outputBytes = Buffer.byteLength(header, "utf8") + Math.max(0, returnedLines - 1);
|
|
1891
|
+
for (const line of selectedLines) outputBytes += width + 1 + Buffer.byteLength(line, "utf8");
|
|
1892
|
+
if (outputBytes > MAX_READ_BYTES) throw new ReadByteLimitError("output");
|
|
776
1893
|
const result = {
|
|
777
1894
|
success: true,
|
|
778
|
-
output:
|
|
1895
|
+
output: header + formatWithLineNumbers(selectedLines, startLine)
|
|
779
1896
|
};
|
|
780
1897
|
return JSON.stringify(result);
|
|
781
1898
|
}
|
|
782
|
-
async function readFileTool(args, options
|
|
783
|
-
|
|
1899
|
+
async function readFileTool(args, options) {
|
|
1900
|
+
if (options.signal?.aborted) throw new ReadCancelledError();
|
|
1901
|
+
const { offset, limit = DEFAULT_LIMIT$1 } = args;
|
|
1902
|
+
const filePath = options.sandboxClient ? args.filePath : resolveHostPath(args.filePath, options.cwd);
|
|
784
1903
|
const startLine = offset !== void 0 && offset > 0 ? offset : 1;
|
|
785
1904
|
if (options.sandboxClient) try {
|
|
786
|
-
|
|
1905
|
+
const content = await options.sandboxClient.readFile(filePath);
|
|
1906
|
+
if (options.signal?.aborted) throw new ReadCancelledError();
|
|
1907
|
+
if (Buffer.byteLength(content, "utf8") > MAX_READ_BYTES) throw new ReadByteLimitError("input");
|
|
1908
|
+
return formatReadResult(filePath, content, startLine, limit);
|
|
787
1909
|
} catch (err) {
|
|
1910
|
+
if (err instanceof ReadByteLimitError || err instanceof ReadCancelledError) throw err;
|
|
788
1911
|
const result = {
|
|
789
1912
|
success: false,
|
|
790
1913
|
output: "",
|
|
@@ -813,10 +1936,35 @@ async function readFileTool(args, options = {}) {
|
|
|
813
1936
|
};
|
|
814
1937
|
return JSON.stringify(result);
|
|
815
1938
|
}
|
|
816
|
-
let buffer;
|
|
1939
|
+
let buffer = Buffer.alloc(0);
|
|
1940
|
+
let binaryFile = false;
|
|
817
1941
|
try {
|
|
818
|
-
|
|
1942
|
+
const handle = await open(filePath, "r");
|
|
1943
|
+
try {
|
|
1944
|
+
const chunks = [];
|
|
1945
|
+
const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES$2);
|
|
1946
|
+
let bytes = 0;
|
|
1947
|
+
let binaryCheckedBytes = 0;
|
|
1948
|
+
while (bytes <= MAX_READ_BYTES) {
|
|
1949
|
+
if (options.signal?.aborted) throw new ReadCancelledError();
|
|
1950
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, 4194305 - bytes), null);
|
|
1951
|
+
if (bytesRead === 0) break;
|
|
1952
|
+
const binaryCheckLength = Math.min(bytesRead, 8192 - binaryCheckedBytes);
|
|
1953
|
+
if (binaryCheckLength > 0 && isBinary(chunk.subarray(0, binaryCheckLength))) {
|
|
1954
|
+
binaryFile = true;
|
|
1955
|
+
break;
|
|
1956
|
+
}
|
|
1957
|
+
binaryCheckedBytes += binaryCheckLength;
|
|
1958
|
+
bytes += bytesRead;
|
|
1959
|
+
if (bytes > MAX_READ_BYTES) throw new ReadByteLimitError("input");
|
|
1960
|
+
chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
|
|
1961
|
+
}
|
|
1962
|
+
if (!binaryFile) buffer = Buffer.concat(chunks, bytes);
|
|
1963
|
+
} finally {
|
|
1964
|
+
await handle.close();
|
|
1965
|
+
}
|
|
819
1966
|
} catch (err) {
|
|
1967
|
+
if (err instanceof ReadByteLimitError || err instanceof ReadCancelledError) throw err;
|
|
820
1968
|
const result = {
|
|
821
1969
|
success: false,
|
|
822
1970
|
output: "",
|
|
@@ -824,7 +1972,8 @@ async function readFileTool(args, options = {}) {
|
|
|
824
1972
|
};
|
|
825
1973
|
return JSON.stringify(result);
|
|
826
1974
|
}
|
|
827
|
-
if (
|
|
1975
|
+
if (options.signal?.aborted) throw new ReadCancelledError();
|
|
1976
|
+
if (binaryFile) {
|
|
828
1977
|
const result = {
|
|
829
1978
|
success: false,
|
|
830
1979
|
output: "",
|
|
@@ -837,25 +1986,30 @@ async function readFileTool(args, options = {}) {
|
|
|
837
1986
|
/**
|
|
838
1987
|
* Create a ReadTool instance — register with Robota agent tools registry.
|
|
839
1988
|
*/
|
|
840
|
-
function createReadTool(options
|
|
841
|
-
return createZodFunctionTool("Read",
|
|
1989
|
+
function createReadTool(options) {
|
|
1990
|
+
return createZodFunctionTool("Read", options.description ?? DEFAULT_READ_DESCRIPTION, ReadSchema, async (params) => {
|
|
842
1991
|
return readFileTool(params, options);
|
|
843
1992
|
});
|
|
844
1993
|
}
|
|
845
|
-
/**
|
|
846
|
-
* ReadTool instance — register with Robota agent tools registry.
|
|
847
|
-
*/
|
|
848
|
-
const readTool = createReadTool();
|
|
849
1994
|
//#endregion
|
|
850
1995
|
//#region src/builtins/atomic-file-write.ts
|
|
851
1996
|
const TEMP_RANDOM_BYTES = 6;
|
|
852
1997
|
const PRESERVED_MODE_BITS = 4095;
|
|
853
1998
|
const MISSING_FILE_ERROR_CODE = "ENOENT";
|
|
1999
|
+
/**
|
|
2000
|
+
* NEUT-009: this marker used to carry the consumer's product name, so a neutral tool library wrote
|
|
2001
|
+
* that name onto every temporary file it created — inherited by any other product built on it. The
|
|
2002
|
+
* marker now says what the file IS, which is all it was ever for.
|
|
2003
|
+
*
|
|
2004
|
+
* The product name is not quoted here either: the ratchet counts prose, deliberately, because a
|
|
2005
|
+
* library whose comments teach the product's layout is coupled to it just as firmly.
|
|
2006
|
+
*/
|
|
2007
|
+
const TEMP_MARKER = ".atomic-tmp-";
|
|
854
2008
|
function createTempFilePath(filePath) {
|
|
855
2009
|
const dir = dirname(filePath);
|
|
856
2010
|
const name = basename(filePath);
|
|
857
2011
|
const suffix = randomBytes(TEMP_RANDOM_BYTES).toString("hex");
|
|
858
|
-
return join(dir, `.${name}
|
|
2012
|
+
return join(dir, `.${name}${TEMP_MARKER}${process.pid}-${Date.now()}-${suffix}`);
|
|
859
2013
|
}
|
|
860
2014
|
async function readExistingMode(filePath) {
|
|
861
2015
|
try {
|
|
@@ -886,12 +2040,14 @@ async function atomicWriteUtf8File(filePath, content) {
|
|
|
886
2040
|
/**
|
|
887
2041
|
* WriteTool — write content to a file, auto-creating parent directories.
|
|
888
2042
|
*/
|
|
2043
|
+
const DEFAULT_WRITE_DESCRIPTION = "Writes a file to the local filesystem. This will overwrite an existing file if one exists.\n\nPrefer the Edit tool for modifying existing files — it only sends the changed text. Use this tool to create new files or for complete rewrites.\n\nParent directories are created automatically when missing.";
|
|
889
2044
|
const WriteSchema = z.object({
|
|
890
2045
|
filePath: z.string().describe("The absolute path to the file to write"),
|
|
891
2046
|
content: z.string().describe("The content to write to the file")
|
|
892
2047
|
});
|
|
893
|
-
async function writeFileTool(args, options
|
|
894
|
-
const {
|
|
2048
|
+
async function writeFileTool(args, options) {
|
|
2049
|
+
const { content } = args;
|
|
2050
|
+
const filePath = options.sandboxClient ? args.filePath : resolveHostPath(args.filePath, options.cwd);
|
|
895
2051
|
if (!options.sandboxClient) {
|
|
896
2052
|
const pathError = checkPathWithinCwd(filePath, options.cwd);
|
|
897
2053
|
if (pathError !== void 0) return pathError;
|
|
@@ -916,15 +2072,11 @@ async function writeFileTool(args, options = {}) {
|
|
|
916
2072
|
/**
|
|
917
2073
|
* Create a WriteTool instance — register with Robota agent tools registry.
|
|
918
2074
|
*/
|
|
919
|
-
function createWriteTool(options
|
|
920
|
-
return createZodFunctionTool("Write",
|
|
2075
|
+
function createWriteTool(options) {
|
|
2076
|
+
return createZodFunctionTool("Write", options.description ?? DEFAULT_WRITE_DESCRIPTION, WriteSchema, async (params) => {
|
|
921
2077
|
return writeFileTool(params, options);
|
|
922
2078
|
});
|
|
923
2079
|
}
|
|
924
|
-
/**
|
|
925
|
-
* WriteTool instance — register with Robota agent tools registry.
|
|
926
|
-
*/
|
|
927
|
-
const writeTool = createWriteTool();
|
|
928
2080
|
//#endregion
|
|
929
2081
|
//#region src/builtins/edit-tool.ts
|
|
930
2082
|
/**
|
|
@@ -933,22 +2085,67 @@ const writeTool = createWriteTool();
|
|
|
933
2085
|
* By default, requires the oldString to appear exactly once in the file
|
|
934
2086
|
* (ensuring surgical edits). Pass replaceAll:true to replace all occurrences.
|
|
935
2087
|
*/
|
|
2088
|
+
const DEFAULT_EDIT_DESCRIPTION = "Performs exact string replacements in files.\n\noldString must exactly match the file's current content, including whitespace and indentation — reading the file first (e.g. with a file-read tool) is the reliable way to copy exact text.\n\nThe edit will FAIL if oldString is not unique in the file. Either provide more surrounding context to make it unique, or set replaceAll to change every instance.";
|
|
936
2089
|
const EditSchema = z.object({
|
|
937
2090
|
filePath: z.string().describe("The absolute path to the file to modify"),
|
|
938
2091
|
oldString: z.string().describe("The text to replace (must be an exact match of existing content)"),
|
|
939
|
-
newString: z.string().describe("The text to replace it with (must be different from
|
|
940
|
-
replaceAll: z.boolean().optional().describe("Replace all occurrences of
|
|
2092
|
+
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
|
2093
|
+
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default: false). Useful for renaming variables")
|
|
941
2094
|
});
|
|
942
|
-
|
|
943
|
-
|
|
2095
|
+
const MAX_EDIT_FILE_BYTES = 4 * 1024 * 1024;
|
|
2096
|
+
const READ_CHUNK_BYTES$1 = 64 * 1024;
|
|
2097
|
+
/** Marks a refusal that must not surface a partial or crashed read to the caller. */
|
|
2098
|
+
var EditByteLimitError = class extends Error {
|
|
2099
|
+
boundary;
|
|
2100
|
+
constructor(boundary) {
|
|
2101
|
+
super(`Edit ${boundary} exceeds its ${MAX_EDIT_FILE_BYTES}-byte limit`);
|
|
2102
|
+
this.boundary = boundary;
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
/**
|
|
2106
|
+
* Read a file as UTF-8 while rejecting as soon as more than `maxBytes` bytes have arrived —
|
|
2107
|
+
* before the whole content is materialized. Reading actual bytes off the stream (rather than
|
|
2108
|
+
* trusting stat() size) also catches a file that grows after being stat'd, or has no stable
|
|
2109
|
+
* size at all (a named pipe).
|
|
2110
|
+
*/
|
|
2111
|
+
async function readBoundedUtf8File(filePath, maxBytes) {
|
|
2112
|
+
const stream = createReadStream(filePath, { highWaterMark: READ_CHUNK_BYTES$1 });
|
|
2113
|
+
const chunks = [];
|
|
2114
|
+
let bytes = 0;
|
|
2115
|
+
try {
|
|
2116
|
+
for await (const chunk of stream) {
|
|
2117
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2118
|
+
bytes += buffer.length;
|
|
2119
|
+
if (bytes > maxBytes) throw new EditByteLimitError("input");
|
|
2120
|
+
chunks.push(buffer);
|
|
2121
|
+
}
|
|
2122
|
+
} finally {
|
|
2123
|
+
stream.destroy();
|
|
2124
|
+
}
|
|
2125
|
+
return Buffer.concat(chunks, bytes).toString("utf8");
|
|
2126
|
+
}
|
|
2127
|
+
async function editFileTool(args, options) {
|
|
2128
|
+
const { oldString, newString, replaceAll = false } = args;
|
|
2129
|
+
const filePath = options.sandboxClient ? args.filePath : resolveHostPath(args.filePath, options.cwd);
|
|
944
2130
|
if (!options.sandboxClient) {
|
|
945
2131
|
const pathError = checkPathWithinCwd(filePath, options.cwd);
|
|
946
2132
|
if (pathError !== void 0) return pathError;
|
|
947
2133
|
}
|
|
948
2134
|
let content;
|
|
949
2135
|
try {
|
|
950
|
-
|
|
2136
|
+
if (options.sandboxClient) {
|
|
2137
|
+
content = await options.sandboxClient.readFile(filePath);
|
|
2138
|
+
if (Buffer.byteLength(content, "utf8") > MAX_EDIT_FILE_BYTES) throw new EditByteLimitError("input");
|
|
2139
|
+
} else content = await readBoundedUtf8File(filePath, MAX_EDIT_FILE_BYTES);
|
|
951
2140
|
} catch (err) {
|
|
2141
|
+
if (err instanceof EditByteLimitError) {
|
|
2142
|
+
const result = {
|
|
2143
|
+
success: false,
|
|
2144
|
+
output: "",
|
|
2145
|
+
error: `${err.message}: ${filePath}`
|
|
2146
|
+
};
|
|
2147
|
+
return JSON.stringify(result);
|
|
2148
|
+
}
|
|
952
2149
|
const result = {
|
|
953
2150
|
success: false,
|
|
954
2151
|
output: "",
|
|
@@ -964,17 +2161,28 @@ async function editFileTool(args, options = {}) {
|
|
|
964
2161
|
};
|
|
965
2162
|
return JSON.stringify(result);
|
|
966
2163
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
|
|
2164
|
+
let parts = [];
|
|
2165
|
+
if (replaceAll) parts = content.split(oldString);
|
|
2166
|
+
else if (content.indexOf(oldString) !== content.lastIndexOf(oldString)) {
|
|
2167
|
+
const result = {
|
|
2168
|
+
success: false,
|
|
2169
|
+
output: "",
|
|
2170
|
+
error: `oldString is not unique in file (found ${content.split(oldString).length - 1} occurrences). Provide more context to make it unique, or use replaceAll:true.`
|
|
2171
|
+
};
|
|
2172
|
+
return JSON.stringify(result);
|
|
2173
|
+
}
|
|
2174
|
+
const count = replaceAll ? parts.length - 1 : 1;
|
|
2175
|
+
const oldBytes = Buffer.byteLength(oldString, "utf8");
|
|
2176
|
+
const newBytes = Buffer.byteLength(newString, "utf8");
|
|
2177
|
+
if (Buffer.byteLength(content, "utf8") - count * oldBytes + count * newBytes > MAX_EDIT_FILE_BYTES) {
|
|
2178
|
+
const result = {
|
|
2179
|
+
success: false,
|
|
2180
|
+
output: "",
|
|
2181
|
+
error: `Edit output exceeds its ${MAX_EDIT_FILE_BYTES}-byte limit: ${filePath}`
|
|
2182
|
+
};
|
|
2183
|
+
return JSON.stringify(result);
|
|
976
2184
|
}
|
|
977
|
-
const updated = replaceAll ?
|
|
2185
|
+
const updated = replaceAll ? parts.join(newString) : content.slice(0, content.indexOf(oldString)) + newString + content.slice(content.indexOf(oldString) + oldString.length);
|
|
978
2186
|
try {
|
|
979
2187
|
if (options.sandboxClient) await options.sandboxClient.writeFile(filePath, updated);
|
|
980
2188
|
else await atomicWriteUtf8File(filePath, updated);
|
|
@@ -986,7 +2194,6 @@ async function editFileTool(args, options = {}) {
|
|
|
986
2194
|
};
|
|
987
2195
|
return JSON.stringify(result);
|
|
988
2196
|
}
|
|
989
|
-
const count = replaceAll ? content.split(oldString).length - 1 : 1;
|
|
990
2197
|
const matchIdx = content.indexOf(oldString);
|
|
991
2198
|
const startLine = matchIdx >= 0 ? content.substring(0, matchIdx).split("\n").length : 1;
|
|
992
2199
|
const result = {
|
|
@@ -999,40 +2206,110 @@ async function editFileTool(args, options = {}) {
|
|
|
999
2206
|
/**
|
|
1000
2207
|
* Create an EditTool instance — register with Robota agent tools registry.
|
|
1001
2208
|
*/
|
|
1002
|
-
function createEditTool(options
|
|
1003
|
-
return createZodFunctionTool("Edit",
|
|
2209
|
+
function createEditTool(options) {
|
|
2210
|
+
return createZodFunctionTool("Edit", options.description ?? DEFAULT_EDIT_DESCRIPTION, EditSchema, async (params) => {
|
|
1004
2211
|
return editFileTool(params, options);
|
|
1005
2212
|
});
|
|
1006
2213
|
}
|
|
2214
|
+
//#endregion
|
|
2215
|
+
//#region src/builtins/glob-tool.ts
|
|
2216
|
+
/**
|
|
2217
|
+
* GlobTool — fast file pattern search using fast-glob.
|
|
2218
|
+
*
|
|
2219
|
+
* Excludes node_modules and .git by default.
|
|
2220
|
+
* Results are sorted by modification time (most recently modified first) among the candidates
|
|
2221
|
+
* enumerated before any candidate ceiling was hit (see DEFAULT_MAX_GLOB_CANDIDATES) — ordering is
|
|
2222
|
+
* not guaranteed across the full match set when the search tree is larger than that ceiling.
|
|
2223
|
+
*
|
|
2224
|
+
* SEC-007: when a containment root is configured the enumeration is confined to it. Listing the
|
|
2225
|
+
* filesystem is a disclosure in its own right — a sandbox that stops the model reading a file but
|
|
2226
|
+
* lets it map everything around that file is not a sandbox.
|
|
2227
|
+
*/
|
|
2228
|
+
const DEFAULT_MAX_RESULTS = 1e3;
|
|
2229
|
+
/**
|
|
2230
|
+
* Ceiling on how many raw glob matches are pulled off `fast-glob`'s match STREAM before enumeration
|
|
2231
|
+
* stops, independent of `limit`/`DEFAULT_MAX_RESULTS`.
|
|
2232
|
+
*
|
|
2233
|
+
* `fg(pattern)` (the promise form) materializes every match into memory and only then stats and
|
|
2234
|
+
* slices to `limit` — a pattern like `**\/*` under a huge tree allocates and stats the whole match
|
|
2235
|
+
* set no matter how small `limit` is. Streaming lets the walk stop as soon as this many CANDIDATES
|
|
2236
|
+
* have been seen, so memory and stat fan-out scale with this ceiling, not with the tree.
|
|
2237
|
+
*/
|
|
2238
|
+
const DEFAULT_MAX_GLOB_CANDIDATES = 5e4;
|
|
2239
|
+
const GlobSchema = z.object({
|
|
2240
|
+
pattern: z.string().describe("The glob pattern to match files against (e.g. \"**/*.ts\", \"src/**/*.tsx\")"),
|
|
2241
|
+
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory. Must be a valid directory path if provided"),
|
|
2242
|
+
limit: z.number().optional().describe("Maximum number of results to return (default: 1000). Use a smaller limit to save context space")
|
|
2243
|
+
});
|
|
2244
|
+
/** Cap on concurrent `stat` calls during the mtime sort, so a large match set cannot storm the FS. */
|
|
2245
|
+
const STAT_CONCURRENCY_LIMIT = 100;
|
|
2246
|
+
/**
|
|
2247
|
+
* Drop every match whose CANONICAL path escapes the containment root, then stat the survivors for the
|
|
2248
|
+
* mtime sort, newest first.
|
|
2249
|
+
*
|
|
2250
|
+
* Containment is decided per RESULT as well as per root (SEC-007): a `..` in the pattern, or an
|
|
2251
|
+
* absolute pattern, produces a match the search root never vouched for. Decided canonically through
|
|
2252
|
+
* the shared guard — a symlink named `escape` is a plain segment, so no amount of segment validation
|
|
2253
|
+
* would catch it.
|
|
2254
|
+
*/
|
|
2255
|
+
async function containedMatchesByMtime(matches, cwd, containmentRoot) {
|
|
2256
|
+
const limit = pLimit(STAT_CONCURRENCY_LIMIT);
|
|
2257
|
+
return (await Promise.all(matches.map((p) => limit(async () => {
|
|
2258
|
+
const absPath = resolve(cwd, p);
|
|
2259
|
+
if (!isWithinCwd(absPath, containmentRoot)) return void 0;
|
|
2260
|
+
try {
|
|
2261
|
+
return {
|
|
2262
|
+
path: p,
|
|
2263
|
+
mtime: (await stat(absPath)).mtimeMs
|
|
2264
|
+
};
|
|
2265
|
+
} catch {
|
|
2266
|
+
return {
|
|
2267
|
+
path: p,
|
|
2268
|
+
mtime: 0
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
})))).filter((entry) => entry !== void 0).sort((a, b) => b.mtime - a.mtime);
|
|
2272
|
+
}
|
|
1007
2273
|
/**
|
|
1008
|
-
*
|
|
2274
|
+
* Pull matches off `fast-glob`'s streaming API one at a time, stopping at `maxCandidates` instead of
|
|
2275
|
+
* materializing the whole match set (see {@link DEFAULT_MAX_GLOB_CANDIDATES}). Exported for tests that
|
|
2276
|
+
* need a smaller ceiling than the real default.
|
|
1009
2277
|
*/
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
2278
|
+
async function collectGlobMatches(pattern, options, maxCandidates) {
|
|
2279
|
+
const matches = [];
|
|
2280
|
+
let truncated = false;
|
|
2281
|
+
const stream = fg.stream(pattern, options);
|
|
2282
|
+
for await (const entry of stream) {
|
|
2283
|
+
if (matches.length >= maxCandidates) {
|
|
2284
|
+
truncated = true;
|
|
2285
|
+
break;
|
|
2286
|
+
}
|
|
2287
|
+
matches.push(entry);
|
|
2288
|
+
}
|
|
2289
|
+
return {
|
|
2290
|
+
matches,
|
|
2291
|
+
truncated
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
1013
2294
|
/**
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
* Results are sorted by modification time (most recently modified first).
|
|
2295
|
+
* Exported (rather than module-private) so tests can drive it with a `maxCandidates` far smaller than
|
|
2296
|
+
* {@link DEFAULT_MAX_GLOB_CANDIDATES} — the real default is too large to exercise cheaply — without
|
|
2297
|
+
* adding any test-only knob to the public `createGlobTool` factory or its schema.
|
|
1018
2298
|
*/
|
|
1019
|
-
|
|
1020
|
-
const GlobSchema = z.object({
|
|
1021
|
-
pattern: z.string().describe("The glob pattern to match files against (e.g. \"**/*.ts\", \"src/**/*.tsx\")"),
|
|
1022
|
-
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory. Must be a valid directory path if provided"),
|
|
1023
|
-
limit: z.number().optional().describe("Maximum number of results to return (default: 1000). Use a smaller limit to save context space")
|
|
1024
|
-
});
|
|
1025
|
-
async function globFileTool(args) {
|
|
2299
|
+
async function globFileTool(args, options, maxCandidates = DEFAULT_MAX_GLOB_CANDIDATES) {
|
|
1026
2300
|
const { pattern, path: basePath } = args;
|
|
1027
|
-
const
|
|
1028
|
-
|
|
2301
|
+
const containmentRoot = options.cwd;
|
|
2302
|
+
const { root: cwd, error: rootError } = resolveSearchRoot(basePath, containmentRoot);
|
|
2303
|
+
if (rootError) return rootError;
|
|
2304
|
+
let candidates;
|
|
1029
2305
|
try {
|
|
1030
|
-
|
|
2306
|
+
candidates = await collectGlobMatches(pattern, {
|
|
1031
2307
|
cwd,
|
|
1032
2308
|
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
1033
2309
|
dot: true,
|
|
1034
|
-
absolute: false
|
|
1035
|
-
|
|
2310
|
+
absolute: false,
|
|
2311
|
+
followSymbolicLinks: false
|
|
2312
|
+
}, maxCandidates);
|
|
1036
2313
|
} catch (err) {
|
|
1037
2314
|
const result = {
|
|
1038
2315
|
success: false,
|
|
@@ -1041,63 +2318,39 @@ async function globFileTool(args) {
|
|
|
1041
2318
|
};
|
|
1042
2319
|
return JSON.stringify(result);
|
|
1043
2320
|
}
|
|
1044
|
-
const
|
|
1045
|
-
const withMtime = await
|
|
1046
|
-
const absPath = resolve(cwd, p);
|
|
1047
|
-
try {
|
|
1048
|
-
return {
|
|
1049
|
-
path: p,
|
|
1050
|
-
mtime: (await stat(absPath)).mtimeMs
|
|
1051
|
-
};
|
|
1052
|
-
} catch {
|
|
1053
|
-
return {
|
|
1054
|
-
path: p,
|
|
1055
|
-
mtime: 0
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
})));
|
|
1059
|
-
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
2321
|
+
const { matches, truncated: candidatesTruncated } = candidates;
|
|
2322
|
+
const withMtime = await containedMatchesByMtime(matches, cwd, containmentRoot);
|
|
1060
2323
|
const maxResults = args.limit ?? DEFAULT_MAX_RESULTS;
|
|
1061
2324
|
const totalMatches = withMtime.length;
|
|
1062
2325
|
const truncated = totalMatches > maxResults;
|
|
1063
2326
|
const sorted = (truncated ? withMtime.slice(0, maxResults) : withMtime).map((f) => f.path);
|
|
1064
2327
|
let output = sorted.length > 0 ? sorted.join("\n") : "(no matches)";
|
|
1065
2328
|
if (truncated) output += `\n\n[Showing ${maxResults} of ${totalMatches} matches. Use limit parameter to see more.]`;
|
|
2329
|
+
if (candidatesTruncated) output += `\n\n[Candidate search stopped early; the search tree has more matches than this tool scans in one call. Results are ordered among the scanned candidates only — narrow the pattern or path to see the rest.]`;
|
|
1066
2330
|
return JSON.stringify({
|
|
1067
2331
|
success: true,
|
|
1068
2332
|
output
|
|
1069
2333
|
});
|
|
1070
2334
|
}
|
|
2335
|
+
const DEFAULT_GLOB_DESCRIPTION = "Fast file pattern matching tool that works with any codebase size.\n\nSupports glob patterns like '**/*.js' or 'src/**/*.ts'. Returns matching file paths sorted by modification time.\n\nUse this tool when you need to find files by name patterns.\n\nDefault limit is 1000 results. Use the limit parameter if you need fewer results to save context space.";
|
|
1071
2336
|
/**
|
|
1072
|
-
* GlobTool instance — register with Robota agent tools registry.
|
|
2337
|
+
* Create a GlobTool instance — register with Robota agent tools registry.
|
|
1073
2338
|
*/
|
|
1074
|
-
|
|
1075
|
-
return
|
|
1076
|
-
|
|
2339
|
+
function createGlobTool(options) {
|
|
2340
|
+
return createZodFunctionTool("Glob", options.description ?? DEFAULT_GLOB_DESCRIPTION, GlobSchema, async (params) => {
|
|
2341
|
+
return globFileTool(params, options);
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
1077
2344
|
//#endregion
|
|
1078
|
-
//#region src/builtins/grep-
|
|
2345
|
+
//#region src/builtins/grep-search.ts
|
|
1079
2346
|
/**
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
* Supports three output modes:
|
|
1083
|
-
* - files_with_matches (default): return only file paths that contain a match
|
|
1084
|
-
* - content: return matching lines with optional context lines
|
|
1085
|
-
* - count: return per-file match counts as "path:count" rows
|
|
2347
|
+
* The `Grep` tool's search internals — file enumeration and per-file matching.
|
|
1086
2348
|
*
|
|
1087
|
-
*
|
|
2349
|
+
* Split out of `grep-tool.ts` (SEC-007) when adding containment pushed that file past the
|
|
2350
|
+
* anti-monolith limit. The split is by responsibility, not by line count: this module is HOW the
|
|
2351
|
+
* search is performed, while `grep-tool.ts` is the tool SURFACE — schema, model-facing description,
|
|
2352
|
+
* factory, and the result envelope. Neither half needs to know the other's concerns.
|
|
1088
2353
|
*/
|
|
1089
|
-
const GrepSchema = z.object({
|
|
1090
|
-
pattern: z.string().describe("The regular expression pattern to search for in file contents"),
|
|
1091
|
-
path: z.string().optional().describe("File or directory to search in. Defaults to the current working directory"),
|
|
1092
|
-
glob: z.string().optional().describe("Glob pattern to filter files (e.g. \"*.ts\", \"*.{ts,tsx}\"). Only files matching this pattern will be searched"),
|
|
1093
|
-
contextLines: z.number().optional().describe("Number of context lines to show before and after each match. Only applies when outputMode is \"content\". Default: 0"),
|
|
1094
|
-
outputMode: z.enum([
|
|
1095
|
-
"files_with_matches",
|
|
1096
|
-
"content",
|
|
1097
|
-
"count"
|
|
1098
|
-
]).optional().describe("Output mode: \"files_with_matches\" shows only file paths (default), \"content\" shows matching lines with context, \"count\" shows per-file match counts"),
|
|
1099
|
-
headLimit: z.number().int().positive().optional().describe("Maximum number of result lines (file paths, content lines, or count rows) to return. Excess results are truncated with a marker line")
|
|
1100
|
-
});
|
|
1101
2354
|
/** Convert a simple glob to a RegExp for file name filtering. */
|
|
1102
2355
|
function globToRegex(glob) {
|
|
1103
2356
|
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".+").replace(/\*/g, "[^/]*");
|
|
@@ -1108,10 +2361,29 @@ function matchesGlob(filename, glob) {
|
|
|
1108
2361
|
if (glob === void 0) return true;
|
|
1109
2362
|
return globToRegex(glob).test(filename);
|
|
1110
2363
|
}
|
|
1111
|
-
/**
|
|
1112
|
-
|
|
2364
|
+
/**
|
|
2365
|
+
* Ceiling on how many directory entries `collectFiles` will `stat` before it stops walking.
|
|
2366
|
+
*
|
|
2367
|
+
* Without a cap, enumeration and stat fan-out scale with the whole tree under the search root,
|
|
2368
|
+
* not with any result limit — a directory with millions of files makes every `Grep` call walk and
|
|
2369
|
+
* stat millions of entries before `headLimit` ever gets a chance to truncate the OUTPUT. This bounds
|
|
2370
|
+
* the WALK itself.
|
|
2371
|
+
*/
|
|
2372
|
+
const DEFAULT_MAX_COLLECTED_FILES = 5e4;
|
|
2373
|
+
/**
|
|
2374
|
+
* Gather files under a directory recursively, excluding node_modules/.git, stopping once `maxFiles`
|
|
2375
|
+
* entries have been visited.
|
|
2376
|
+
*
|
|
2377
|
+
* `containmentRoot` (SEC-007) drops any entry whose CANONICAL path escapes the root, before it is
|
|
2378
|
+
* descended into or read. `stat` follows symlinks, so without this a link inside the root pointing
|
|
2379
|
+
* out of it made the whole target tree readable — including, for a symlinked FILE, its contents.
|
|
2380
|
+
*/
|
|
2381
|
+
async function collectFiles(dirPath, glob, containmentRoot, maxFiles = DEFAULT_MAX_COLLECTED_FILES) {
|
|
1113
2382
|
const results = [];
|
|
2383
|
+
let visited = 0;
|
|
2384
|
+
let truncated = false;
|
|
1114
2385
|
async function walk(current) {
|
|
2386
|
+
if (truncated) return;
|
|
1115
2387
|
let entryNames;
|
|
1116
2388
|
try {
|
|
1117
2389
|
entryNames = await readdir(current);
|
|
@@ -1119,8 +2391,15 @@ async function collectFiles(dirPath, glob) {
|
|
|
1119
2391
|
return;
|
|
1120
2392
|
}
|
|
1121
2393
|
for (const name of entryNames) {
|
|
2394
|
+
if (truncated) return;
|
|
1122
2395
|
if (name === "node_modules" || name === ".git") continue;
|
|
1123
2396
|
const fullPath = join(current, name);
|
|
2397
|
+
if (!isWithinCwd(fullPath, containmentRoot)) continue;
|
|
2398
|
+
if (visited >= maxFiles) {
|
|
2399
|
+
truncated = true;
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
visited++;
|
|
1124
2403
|
let fileStat;
|
|
1125
2404
|
try {
|
|
1126
2405
|
fileStat = await stat(fullPath);
|
|
@@ -1134,10 +2413,13 @@ async function collectFiles(dirPath, glob) {
|
|
|
1134
2413
|
}
|
|
1135
2414
|
}
|
|
1136
2415
|
await walk(dirPath);
|
|
1137
|
-
return
|
|
2416
|
+
return {
|
|
2417
|
+
files: results,
|
|
2418
|
+
truncated
|
|
2419
|
+
};
|
|
1138
2420
|
}
|
|
1139
2421
|
/** Search a single file for lines matching the regex. */
|
|
1140
|
-
function searchFile(content, filePath, regex, contextLines, outputMode) {
|
|
2422
|
+
function searchFile(content, filePath, regex, contextLines, outputMode, maxOutputBytes) {
|
|
1141
2423
|
const lines = content.split("\n");
|
|
1142
2424
|
const matchingIndices = [];
|
|
1143
2425
|
for (let i = 0; i < lines.length; i++) if (regex.test(lines[i])) matchingIndices.push(i);
|
|
@@ -1147,23 +2429,229 @@ function searchFile(content, filePath, regex, contextLines, outputMode) {
|
|
|
1147
2429
|
const includedIndices = /* @__PURE__ */ new Set();
|
|
1148
2430
|
for (const idx of matchingIndices) for (let c = Math.max(0, idx - contextLines); c <= Math.min(lines.length - 1, idx + contextLines); c++) includedIndices.add(c);
|
|
1149
2431
|
const outputLines = [];
|
|
2432
|
+
let outputBytes = 0;
|
|
1150
2433
|
const sortedIndices = Array.from(includedIndices).sort((a, b) => a - b);
|
|
1151
2434
|
let prevIdx;
|
|
2435
|
+
let matchingCursor = 0;
|
|
1152
2436
|
for (const idx of sortedIndices) {
|
|
1153
2437
|
if (prevIdx !== void 0 && idx > prevIdx + 1) outputLines.push("--");
|
|
1154
2438
|
const lineNum = idx + 1;
|
|
1155
|
-
|
|
1156
|
-
|
|
2439
|
+
while (matchingIndices[matchingCursor] < idx) matchingCursor++;
|
|
2440
|
+
const row = `${filePath}:${lineNum}${matchingIndices[matchingCursor] === idx ? ":" : "-"}${lines[idx]}`;
|
|
2441
|
+
outputBytes += Buffer.byteLength(row, "utf8") + 1;
|
|
2442
|
+
if (maxOutputBytes !== void 0 && outputBytes > maxOutputBytes) throw new Error("byte limit");
|
|
2443
|
+
outputLines.push(row);
|
|
1157
2444
|
prevIdx = idx;
|
|
1158
2445
|
}
|
|
1159
2446
|
return outputLines;
|
|
1160
2447
|
}
|
|
1161
|
-
|
|
2448
|
+
//#endregion
|
|
2449
|
+
//#region src/builtins/isolated-grep-search.ts
|
|
2450
|
+
const BOOTSTRAP = `
|
|
2451
|
+
const { parentPort } = require('node:worker_threads');
|
|
2452
|
+
const searchFile = ${searchFile.toString()};
|
|
2453
|
+
let outputBytes = 0;
|
|
2454
|
+
parentPort.on('message', (request) => {
|
|
2455
|
+
try {
|
|
2456
|
+
const regex = new RegExp(request.pattern);
|
|
2457
|
+
const matches = searchFile(request.content, request.filePath, regex, request.contextLines, request.outputMode, 4 * 1024 * 1024);
|
|
2458
|
+
let bytes = 0;
|
|
2459
|
+
for (const match of matches) { bytes += Buffer.byteLength(match, 'utf8') + 1; if (outputBytes + bytes > 4 * 1024 * 1024) throw new Error('byte limit'); }
|
|
2460
|
+
outputBytes += bytes;
|
|
2461
|
+
parentPort.postMessage({ id: request.id, matches });
|
|
2462
|
+
} catch (error) { parentPort.postMessage({ id: request.id, error: error?.message === 'byte limit' ? 'Grep search exceeded its byte limit' : 'Invalid grep regex execution' }); }
|
|
2463
|
+
});
|
|
2464
|
+
`;
|
|
2465
|
+
var GrepProcessWorker = class extends EventEmitter {
|
|
2466
|
+
child = spawn(process.execPath, ["-e", `
|
|
2467
|
+
const searchFile = ${searchFile.toString()};
|
|
2468
|
+
const readline = require('node:readline');
|
|
2469
|
+
let outputBytes = 0;
|
|
2470
|
+
readline.createInterface({ input: process.stdin }).on('line', line => {
|
|
2471
|
+
const request = JSON.parse(line);
|
|
2472
|
+
try {
|
|
2473
|
+
const regex = new RegExp(request.pattern);
|
|
2474
|
+
const matches = searchFile(request.content, request.filePath, regex, request.contextLines, request.outputMode, 4 * 1024 * 1024);
|
|
2475
|
+
let bytes = 0;
|
|
2476
|
+
for (const match of matches) { bytes += Buffer.byteLength(match, 'utf8') + 1; if (outputBytes + bytes > 4 * 1024 * 1024) throw new Error('byte limit'); }
|
|
2477
|
+
outputBytes += bytes;
|
|
2478
|
+
process.stdout.write(JSON.stringify({ id: request.id, matches }) + String.fromCharCode(10));
|
|
2479
|
+
} catch (error) { process.stdout.write(JSON.stringify({ id: request.id, error: error?.message === 'byte limit' ? 'Grep search exceeded its byte limit' : 'Invalid grep regex execution' }) + String.fromCharCode(10)); }
|
|
2480
|
+
});
|
|
2481
|
+
`], {
|
|
2482
|
+
env: {
|
|
2483
|
+
BUN_BE_BUN: "1",
|
|
2484
|
+
...process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}
|
|
2485
|
+
},
|
|
2486
|
+
cwd: tmpdir(),
|
|
2487
|
+
stdio: "pipe"
|
|
2488
|
+
});
|
|
2489
|
+
closed;
|
|
2490
|
+
constructor() {
|
|
2491
|
+
super();
|
|
2492
|
+
let pending = "";
|
|
2493
|
+
this.child.stdout.setEncoding("utf8");
|
|
2494
|
+
this.child.stdout.on("data", (chunk) => {
|
|
2495
|
+
pending += chunk;
|
|
2496
|
+
if (Buffer.byteLength(pending, "utf8") > 25166848) {
|
|
2497
|
+
this.emit("error");
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
let newline;
|
|
2501
|
+
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
2502
|
+
const line = pending.slice(0, newline);
|
|
2503
|
+
pending = pending.slice(newline + 1);
|
|
2504
|
+
try {
|
|
2505
|
+
this.emit("message", JSON.parse(line));
|
|
2506
|
+
} catch {
|
|
2507
|
+
this.emit("error");
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
this.child.stderr.resume();
|
|
2512
|
+
this.child.on("error", () => this.emit("error"));
|
|
2513
|
+
this.child.stdin.on("error", () => this.emit("error"));
|
|
2514
|
+
this.closed = new Promise((resolve) => {
|
|
2515
|
+
this.child.once("close", () => {
|
|
2516
|
+
this.emit("exit");
|
|
2517
|
+
resolve();
|
|
2518
|
+
});
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
postMessage(request) {
|
|
2522
|
+
this.child.stdin.write(JSON.stringify(request) + "\n");
|
|
2523
|
+
}
|
|
2524
|
+
async terminate() {
|
|
2525
|
+
if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL");
|
|
2526
|
+
await this.closed;
|
|
2527
|
+
}
|
|
2528
|
+
};
|
|
2529
|
+
/** One worker per grep invocation; a deadline or abort terminates it before the failure is exposed. */
|
|
2530
|
+
var IsolatedGrepSearch = class {
|
|
2531
|
+
pattern;
|
|
2532
|
+
signal;
|
|
2533
|
+
worker = process.versions.bun ? new GrepProcessWorker() : new Worker(BOOTSTRAP, {
|
|
2534
|
+
eval: true,
|
|
2535
|
+
execArgv: [],
|
|
2536
|
+
resourceLimits: {
|
|
2537
|
+
maxOldGenerationSizeMb: 128,
|
|
2538
|
+
maxYoungGenerationSizeMb: 32
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
pending = /* @__PURE__ */ new Map();
|
|
2542
|
+
nextId = 0;
|
|
2543
|
+
stopped = false;
|
|
2544
|
+
termination;
|
|
2545
|
+
timer;
|
|
2546
|
+
abort = () => {
|
|
2547
|
+
this.stop(/* @__PURE__ */ new Error("Grep search cancelled"));
|
|
2548
|
+
};
|
|
2549
|
+
constructor(pattern, signal) {
|
|
2550
|
+
this.pattern = pattern;
|
|
2551
|
+
this.signal = signal;
|
|
2552
|
+
this.worker.on("message", (message) => {
|
|
2553
|
+
const pending = this.pending.get(message.id);
|
|
2554
|
+
if (!pending || this.stopped) return;
|
|
2555
|
+
this.pending.delete(message.id);
|
|
2556
|
+
if (Array.isArray(message.matches) && message.matches.every((m) => typeof m === "string")) pending.resolve(message.matches);
|
|
2557
|
+
else pending.reject(new Error(message.error ?? "Invalid grep worker response"));
|
|
2558
|
+
});
|
|
2559
|
+
this.worker.on("error", () => {
|
|
2560
|
+
this.stop(/* @__PURE__ */ new Error("Grep search worker failed"));
|
|
2561
|
+
});
|
|
2562
|
+
this.worker.once("exit", () => {
|
|
2563
|
+
this.stop(/* @__PURE__ */ new Error("Grep search worker exited"));
|
|
2564
|
+
});
|
|
2565
|
+
this.timer = setTimeout(() => {
|
|
2566
|
+
this.stop(/* @__PURE__ */ new Error("Grep search timed out"));
|
|
2567
|
+
}, 2e3);
|
|
2568
|
+
signal?.addEventListener("abort", this.abort, { once: true });
|
|
2569
|
+
if (signal?.aborted) this.abort();
|
|
2570
|
+
}
|
|
2571
|
+
search(content, filePath, contextLines, outputMode) {
|
|
2572
|
+
if (this.stopped) return Promise.reject(/* @__PURE__ */ new Error(this.signal?.aborted ? "Grep search cancelled" : "Grep search timed out"));
|
|
2573
|
+
const id = this.nextId++;
|
|
2574
|
+
return new Promise((resolve, reject) => {
|
|
2575
|
+
this.pending.set(id, {
|
|
2576
|
+
resolve,
|
|
2577
|
+
reject
|
|
2578
|
+
});
|
|
2579
|
+
try {
|
|
2580
|
+
this.worker.postMessage({
|
|
2581
|
+
id,
|
|
2582
|
+
content,
|
|
2583
|
+
filePath,
|
|
2584
|
+
pattern: this.pattern,
|
|
2585
|
+
contextLines,
|
|
2586
|
+
outputMode
|
|
2587
|
+
});
|
|
2588
|
+
} catch {
|
|
2589
|
+
this.stop(/* @__PURE__ */ new Error("Grep search worker failed"));
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
async stop(error) {
|
|
2594
|
+
if (this.termination) return this.termination;
|
|
2595
|
+
this.stopped = true;
|
|
2596
|
+
clearTimeout(this.timer);
|
|
2597
|
+
this.signal?.removeEventListener("abort", this.abort);
|
|
2598
|
+
this.termination = (async () => {
|
|
2599
|
+
try {
|
|
2600
|
+
await this.worker.terminate();
|
|
2601
|
+
} catch {}
|
|
2602
|
+
for (const pending of this.pending.values()) pending.reject(error ?? /* @__PURE__ */ new Error("Grep search stopped"));
|
|
2603
|
+
this.pending.clear();
|
|
2604
|
+
})();
|
|
2605
|
+
return this.termination;
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
//#endregion
|
|
2609
|
+
//#region src/builtins/grep-tool.ts
|
|
2610
|
+
/**
|
|
2611
|
+
* GrepTool — recursive regex content search.
|
|
2612
|
+
*
|
|
2613
|
+
* Supports three output modes:
|
|
2614
|
+
* - files_with_matches (default): return only file paths that contain a match
|
|
2615
|
+
* - content: return matching lines with optional context lines
|
|
2616
|
+
* - count: return per-file match counts as "path:count" rows
|
|
2617
|
+
*
|
|
2618
|
+
* headLimit caps the number of result lines; excess is truncated with a marker.
|
|
2619
|
+
*
|
|
2620
|
+
* SEC-007: when a containment root is configured the search is confined to it. Grep is the most
|
|
2621
|
+
* disclosing of the file tools — `content` mode returns the matching LINES — so it must be contained
|
|
2622
|
+
* at least as strictly as `Read`, which it could otherwise stand in for.
|
|
2623
|
+
*/
|
|
2624
|
+
const GrepSchema = z.object({
|
|
2625
|
+
pattern: z.string().describe("The regular expression pattern to search for in file contents"),
|
|
2626
|
+
path: z.string().optional().describe("File or directory to search in. Defaults to the current working directory"),
|
|
2627
|
+
glob: z.string().optional().describe("Glob pattern to filter files (e.g. \"*.ts\", \"*.{ts,tsx}\"). Only files matching this pattern will be searched"),
|
|
2628
|
+
contextLines: z.number().optional().describe("Number of context lines to show before and after each match. Only applies when outputMode is \"content\". Default: 0"),
|
|
2629
|
+
outputMode: z.enum([
|
|
2630
|
+
"files_with_matches",
|
|
2631
|
+
"content",
|
|
2632
|
+
"count"
|
|
2633
|
+
]).optional().describe("Output mode: \"files_with_matches\" shows only file paths (default), \"content\" shows matching lines with context, \"count\" shows per-file match counts"),
|
|
2634
|
+
headLimit: z.number().int().positive().optional().describe("Maximum number of result lines (file paths, content lines, or count rows) to return. Excess results are truncated with a marker line")
|
|
2635
|
+
});
|
|
2636
|
+
/** The matcher consumes one file at a time; keep only a few reads outstanding. */
|
|
2637
|
+
const READ_CONCURRENCY_LIMIT = 8;
|
|
2638
|
+
const MAX_GREP_FILE_BYTES = 4 * 1024 * 1024;
|
|
2639
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
2640
|
+
/** A grep isolation failure is a hard tool failure, distinct from ordinary no-match/invalid-input results. */
|
|
2641
|
+
var GrepIsolationError = class extends ToolExecutionError {
|
|
2642
|
+
reason;
|
|
2643
|
+
constructor(reason) {
|
|
2644
|
+
super(`Grep search ${reason === "timeout" ? "timed out" : reason === "cancelled" ? "cancelled" : reason === "limit" ? "exceeded its byte limit" : "worker failed"}`, "Grep");
|
|
2645
|
+
this.reason = reason;
|
|
2646
|
+
}
|
|
2647
|
+
};
|
|
2648
|
+
async function grepFileTool(args, options) {
|
|
1162
2649
|
const { pattern, path: searchPath, glob, contextLines = 0, outputMode = "files_with_matches", headLimit } = args;
|
|
1163
|
-
const
|
|
1164
|
-
|
|
2650
|
+
const containmentRoot = options.cwd;
|
|
2651
|
+
const { root: targetPath, error: rootError } = resolveSearchRoot(searchPath, containmentRoot);
|
|
2652
|
+
if (rootError) return rootError;
|
|
1165
2653
|
try {
|
|
1166
|
-
|
|
2654
|
+
new RegExp(pattern);
|
|
1167
2655
|
} catch (err) {
|
|
1168
2656
|
const result = {
|
|
1169
2657
|
success: false,
|
|
@@ -1184,51 +2672,124 @@ async function grepFileTool(args) {
|
|
|
1184
2672
|
return JSON.stringify(result);
|
|
1185
2673
|
}
|
|
1186
2674
|
let files;
|
|
2675
|
+
let filesTruncated = false;
|
|
1187
2676
|
if (targetStat.isFile()) files = [targetPath];
|
|
1188
|
-
else
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
2677
|
+
else {
|
|
2678
|
+
const collected = await collectFiles(targetPath, glob, containmentRoot);
|
|
2679
|
+
files = collected.files;
|
|
2680
|
+
filesTruncated = collected.truncated;
|
|
2681
|
+
}
|
|
2682
|
+
const search = new IsolatedGrepSearch(pattern, options.signal);
|
|
2683
|
+
const readAbort = new AbortController();
|
|
2684
|
+
const abortReads = () => readAbort.abort();
|
|
2685
|
+
options.signal?.addEventListener("abort", abortReads, { once: true });
|
|
2686
|
+
if (options.signal?.aborted) abortReads();
|
|
2687
|
+
let perFileMatches;
|
|
2688
|
+
try {
|
|
2689
|
+
if (readAbort.signal.aborted) throw new GrepIsolationError("cancelled");
|
|
2690
|
+
const orderedMatches = new Array(files.length);
|
|
2691
|
+
let nextFile = 0;
|
|
2692
|
+
let failure;
|
|
2693
|
+
const readAndSearch = async (filePath) => {
|
|
2694
|
+
let content;
|
|
2695
|
+
try {
|
|
2696
|
+
if ((await stat(filePath)).size > MAX_GREP_FILE_BYTES) throw new GrepIsolationError("limit");
|
|
2697
|
+
const stream = createReadStream(filePath, {
|
|
2698
|
+
highWaterMark: READ_CHUNK_BYTES,
|
|
2699
|
+
signal: readAbort.signal
|
|
2700
|
+
});
|
|
2701
|
+
const chunks = [];
|
|
2702
|
+
let bytes = 0;
|
|
2703
|
+
try {
|
|
2704
|
+
for await (const chunk of stream) {
|
|
2705
|
+
if (readAbort.signal.aborted) throw new GrepIsolationError("cancelled");
|
|
2706
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2707
|
+
bytes += buffer.length;
|
|
2708
|
+
if (bytes > MAX_GREP_FILE_BYTES) throw new GrepIsolationError("limit");
|
|
2709
|
+
chunks.push(buffer);
|
|
2710
|
+
}
|
|
2711
|
+
} finally {
|
|
2712
|
+
stream.destroy();
|
|
2713
|
+
}
|
|
2714
|
+
const buffer = Buffer.concat(chunks, bytes);
|
|
2715
|
+
const checkLen = Math.min(buffer.length, 8192);
|
|
2716
|
+
let hasBinary = false;
|
|
2717
|
+
for (let i = 0; i < checkLen; i++) if (buffer[i] === 0) {
|
|
2718
|
+
hasBinary = true;
|
|
2719
|
+
break;
|
|
2720
|
+
}
|
|
2721
|
+
if (hasBinary) return [];
|
|
2722
|
+
content = buffer.toString("utf8");
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
if (error instanceof GrepIsolationError) throw error;
|
|
2725
|
+
if (readAbort.signal.aborted) throw new GrepIsolationError("cancelled");
|
|
2726
|
+
return [];
|
|
1199
2727
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
2728
|
+
return search.search(content, filePath, contextLines, outputMode);
|
|
2729
|
+
};
|
|
2730
|
+
const worker = async () => {
|
|
2731
|
+
while (failure === void 0 && nextFile < files.length) {
|
|
2732
|
+
const index = nextFile++;
|
|
2733
|
+
try {
|
|
2734
|
+
orderedMatches[index] = await readAndSearch(files[index]);
|
|
2735
|
+
} catch (error) {
|
|
2736
|
+
if (failure === void 0) {
|
|
2737
|
+
failure = error;
|
|
2738
|
+
readAbort.abort();
|
|
2739
|
+
search.stop(error instanceof Error ? error : /* @__PURE__ */ new Error("Grep search failed"));
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
};
|
|
2744
|
+
await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY_LIMIT, files.length) }, worker));
|
|
2745
|
+
if (failure !== void 0) throw failure;
|
|
2746
|
+
perFileMatches = orderedMatches;
|
|
2747
|
+
} catch (error) {
|
|
2748
|
+
const message = error instanceof Error ? error.message : "";
|
|
2749
|
+
throw error instanceof GrepIsolationError ? error : new GrepIsolationError(message.includes("timed out") ? "timeout" : message.includes("cancelled") ? "cancelled" : message.includes("byte limit") ? "limit" : "failed");
|
|
2750
|
+
} finally {
|
|
2751
|
+
options.signal?.removeEventListener("abort", abortReads);
|
|
2752
|
+
await search.stop();
|
|
2753
|
+
}
|
|
2754
|
+
let outputBytes = 0;
|
|
2755
|
+
for (const matches of perFileMatches) for (const match of matches) {
|
|
2756
|
+
outputBytes += Buffer.byteLength(match, "utf8") + 1;
|
|
2757
|
+
if (outputBytes > 4 * 1024 * 1024) throw new GrepIsolationError("limit");
|
|
2758
|
+
}
|
|
2759
|
+
let outputLines = perFileMatches.flat();
|
|
1209
2760
|
if (headLimit !== void 0 && outputLines.length > headLimit) {
|
|
1210
2761
|
const truncatedCount = outputLines.length - headLimit;
|
|
1211
2762
|
outputLines = [...outputLines.slice(0, headLimit), `(+${truncatedCount} more results truncated by headLimit)`];
|
|
1212
2763
|
}
|
|
2764
|
+
if (filesTruncated) outputLines = [...outputLines, `[File enumeration stopped early; the search tree has more files than this tool scans in one call. Results may be incomplete — narrow the path or glob.]`];
|
|
1213
2765
|
const result = {
|
|
1214
2766
|
success: true,
|
|
1215
2767
|
output: outputLines.length > 0 ? outputLines.join("\n") : "(no matches)"
|
|
1216
2768
|
};
|
|
1217
2769
|
return JSON.stringify(result);
|
|
1218
2770
|
}
|
|
2771
|
+
/** The registered name of the shell tool this package's default assembly ships (NEUT-002). */
|
|
2772
|
+
const DEFAULT_SHELL_TOOL_NAME = "Shell";
|
|
2773
|
+
/** Build the default description, referencing the actually-registered shell tool by name. */
|
|
2774
|
+
function buildGrepDescription(shellToolName) {
|
|
2775
|
+
return `A powerful search tool built on regex matching.\n\nSupports full regex syntax (e.g., 'log.*Error', 'function\\\\s+\\\\w+'). Filter files with glob parameter (e.g., '*.js', '**/*.tsx').\n\nOutput modes: 'content' shows matching lines with context, 'files_with_matches' shows only file paths (default), 'count' shows per-file match counts.\n\nPrefer this tool over running grep or rg through the ${shellToolName} tool — it returns structured results directly.\n\nUse headLimit to control result size and save context space.`;
|
|
2776
|
+
}
|
|
1219
2777
|
/**
|
|
1220
|
-
* GrepTool instance — register with Robota agent tools registry.
|
|
2778
|
+
* Create a GrepTool instance — register with Robota agent tools registry.
|
|
1221
2779
|
*/
|
|
1222
|
-
|
|
1223
|
-
return
|
|
1224
|
-
|
|
2780
|
+
function createGrepTool(options) {
|
|
2781
|
+
return createZodFunctionTool("Grep", options.description ?? buildGrepDescription(options.shellToolName ?? DEFAULT_SHELL_TOOL_NAME), GrepSchema, async (params) => {
|
|
2782
|
+
return grepFileTool(params, options);
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
1225
2785
|
//#endregion
|
|
1226
2786
|
//#region src/builtins/web-fetch-tool.ts
|
|
1227
2787
|
/**
|
|
1228
2788
|
* WebFetchTool — fetch a URL and return its content as text.
|
|
1229
2789
|
*
|
|
1230
|
-
* HTML is stripped to plain text for readability.
|
|
1231
|
-
*
|
|
2790
|
+
* HTML is stripped to plain text for readability. Fetches through the shared egress boundary
|
|
2791
|
+
* (`fetchWithEgressPolicy`, #2026): loopback / private / link-local / metadata destinations are
|
|
2792
|
+
* refused, redirects are re-validated, and the response is capped while streaming.
|
|
1232
2793
|
*/
|
|
1233
2794
|
const DEFAULT_TIMEOUT_MS$1 = 3e4;
|
|
1234
2795
|
const MAX_RESPONSE_BYTES = 5e6;
|
|
@@ -1236,9 +2797,83 @@ const WebFetchSchema = z.object({
|
|
|
1236
2797
|
url: z.string().describe("The URL to fetch"),
|
|
1237
2798
|
headers: z.record(z.string()).optional().describe("Optional HTTP headers as key-value pairs")
|
|
1238
2799
|
});
|
|
2800
|
+
/**
|
|
2801
|
+
* Remove every `<tag>…</tag>` element — the linear equivalent of `replace(/<tag[\s\S]*?<\/tag>/gi, '')`.
|
|
2802
|
+
*
|
|
2803
|
+
* The regex form is quadratic: every `<tag` with no closing tag after it rescans to end of input, and the scan
|
|
2804
|
+
* then restarts at the next one. `htmlToText`'s input is a **response body from an arbitrary URL**, capped only
|
|
2805
|
+
* at {@link MAX_RESPONSE_BYTES} (5 MB) — 5 MB of `<script` would have taken minutes. Because the closing tag is
|
|
2806
|
+
* searched forward, its absence at one opener means no later opener can have one either, so the scan stops.
|
|
2807
|
+
*
|
|
2808
|
+
* Case folding is `[A-Z]`-only, not `toLowerCase()`: `toLowerCase()` can change a string's LENGTH (U+0130
|
|
2809
|
+
* lowercases to two code units), which would desynchronise the indices from the original text.
|
|
2810
|
+
*/
|
|
2811
|
+
function stripElement(html, tag) {
|
|
2812
|
+
const openTag = `<${tag}`;
|
|
2813
|
+
const closeTag = `</${tag}>`;
|
|
2814
|
+
const haystack = html.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
2815
|
+
const parts = [];
|
|
2816
|
+
let cursor = 0;
|
|
2817
|
+
for (;;) {
|
|
2818
|
+
const open = haystack.indexOf(openTag, cursor);
|
|
2819
|
+
if (open < 0) break;
|
|
2820
|
+
const close = haystack.indexOf(closeTag, open + openTag.length);
|
|
2821
|
+
if (close < 0) break;
|
|
2822
|
+
parts.push(html.slice(cursor, open));
|
|
2823
|
+
cursor = close + closeTag.length;
|
|
2824
|
+
}
|
|
2825
|
+
parts.push(html.slice(cursor));
|
|
2826
|
+
return parts.join("");
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Replace every `<…>` tag with a space — the linear equivalent of `replace(/<[^>]+>/g, ' ')`.
|
|
2830
|
+
*
|
|
2831
|
+
* Same defect, same input: `[^>]+` cannot cross a `>`, so a `<` with no `>` after it consumed the rest of the
|
|
2832
|
+
* document and then backtracked over it, once per `<`. A page of 200 K `<` characters took 12.6 s; the 5 MB the
|
|
2833
|
+
* fetch allows would have taken hours. `close === open + 1` reproduces the regex's `+` (a tag body must be at
|
|
2834
|
+
* least one character), so a literal `<>` is left in the text exactly as before.
|
|
2835
|
+
*/
|
|
2836
|
+
function stripTags(html) {
|
|
2837
|
+
const parts = [];
|
|
2838
|
+
let cursor = 0;
|
|
2839
|
+
for (;;) {
|
|
2840
|
+
const open = html.indexOf("<", cursor);
|
|
2841
|
+
if (open < 0) break;
|
|
2842
|
+
const close = html.indexOf(">", open + 1);
|
|
2843
|
+
if (close < 0) break;
|
|
2844
|
+
if (close === open + 1) {
|
|
2845
|
+
parts.push(html.slice(cursor, open + 1));
|
|
2846
|
+
cursor = open + 1;
|
|
2847
|
+
continue;
|
|
2848
|
+
}
|
|
2849
|
+
parts.push(html.slice(cursor, open), " ");
|
|
2850
|
+
cursor = close + 1;
|
|
2851
|
+
}
|
|
2852
|
+
parts.push(html.slice(cursor));
|
|
2853
|
+
return parts.join("");
|
|
2854
|
+
}
|
|
2855
|
+
/**
|
|
2856
|
+
* The character entities {@link htmlToText} decodes, and the single alternation that matches them.
|
|
2857
|
+
*
|
|
2858
|
+
* SEC-004 (`js/double-escaping`): decoding these by CHAINED `.replace()` calls with `&` first
|
|
2859
|
+
* decodes twice. `&lt;` — how a page encodes the literal text `<` so a browser DISPLAYS it —
|
|
2860
|
+
* became `<` after the `&` pass and then `<` after the `<` pass, so a page reading
|
|
2861
|
+
* `&lt;script&gt;` came back out of a tag-stripping converter as `<script>`. One pass over
|
|
2862
|
+
* one alternation decodes each entity exactly once and never rescans its own output, so the decoder
|
|
2863
|
+
* is the inverse of the encoder for every input rather than only for singly-encoded ones.
|
|
2864
|
+
*/
|
|
2865
|
+
const HTML_ENTITIES = {
|
|
2866
|
+
"&": "&",
|
|
2867
|
+
"<": "<",
|
|
2868
|
+
">": ">",
|
|
2869
|
+
""": "\"",
|
|
2870
|
+
"'": "'",
|
|
2871
|
+
" ": " "
|
|
2872
|
+
};
|
|
2873
|
+
const HTML_ENTITY_PATTERN = /&(?:amp|lt|gt|quot|nbsp|#39);/g;
|
|
1239
2874
|
/** Strip HTML tags and decode common entities to produce readable text. */
|
|
1240
2875
|
function htmlToText(html) {
|
|
1241
|
-
return
|
|
2876
|
+
return stripTags(stripElement(stripElement(html, "script"), "style")).replace(HTML_ENTITY_PATTERN, (entity) => HTML_ENTITIES[entity]).replace(/\s+/g, " ").trim();
|
|
1242
2877
|
}
|
|
1243
2878
|
function classifyFetchError(err) {
|
|
1244
2879
|
if (!(err instanceof Error)) return String(err);
|
|
@@ -1251,7 +2886,7 @@ function classifyFetchError(err) {
|
|
|
1251
2886
|
if (code === "CERT_HAS_EXPIRED" || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") return `Network error: SSL certificate error (${code}). The server's certificate is invalid. Do not retry with the same URL.`;
|
|
1252
2887
|
return `Network error: ${err.message} Check that the URL is correct and the server is reachable.`;
|
|
1253
2888
|
}
|
|
1254
|
-
async function runWebFetch(args, signal) {
|
|
2889
|
+
async function runWebFetch(args, egress, signal) {
|
|
1255
2890
|
const { url, headers } = args;
|
|
1256
2891
|
try {
|
|
1257
2892
|
new URL(url);
|
|
@@ -1264,38 +2899,35 @@ async function runWebFetch(args, signal) {
|
|
|
1264
2899
|
return JSON.stringify(result);
|
|
1265
2900
|
}
|
|
1266
2901
|
try {
|
|
1267
|
-
const
|
|
1268
|
-
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS$1);
|
|
1269
|
-
const fetchSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
1270
|
-
const response = await fetch(url, {
|
|
2902
|
+
const response = await fetchWithEgressPolicy(url, {
|
|
1271
2903
|
headers: {
|
|
1272
2904
|
"User-Agent": "Robota-CLI/3.0",
|
|
1273
2905
|
...headers ?? {}
|
|
1274
2906
|
},
|
|
1275
|
-
signal
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
2907
|
+
signal,
|
|
2908
|
+
timeoutMs: DEFAULT_TIMEOUT_MS$1,
|
|
2909
|
+
maxResponseBytes: MAX_RESPONSE_BYTES
|
|
2910
|
+
}, egress.policy, egress.deps);
|
|
1279
2911
|
if (!response.ok) {
|
|
1280
|
-
const
|
|
2912
|
+
const { rejection } = response;
|
|
1281
2913
|
const result = {
|
|
1282
2914
|
success: false,
|
|
1283
2915
|
output: "",
|
|
1284
|
-
error: `
|
|
2916
|
+
error: rejection.reason === "response_too_large" ? `Response too large (max ${MAX_RESPONSE_BYTES} bytes). Consider fetching a more specific URL or a paginated endpoint.` : `Blocked by egress policy: ${rejection.message} Do not retry with the same URL.`
|
|
1285
2917
|
};
|
|
1286
2918
|
return JSON.stringify(result);
|
|
1287
2919
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if (buffer.byteLength > MAX_RESPONSE_BYTES) {
|
|
2920
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2921
|
+
const retryHint = response.status >= 500 ? " The server is temporarily unavailable — retrying may help." : " Do not retry with the same URL.";
|
|
1291
2922
|
const result = {
|
|
1292
2923
|
success: false,
|
|
1293
2924
|
output: "",
|
|
1294
|
-
error: `
|
|
2925
|
+
error: `HTTP ${response.status} ${response.statusText}.${retryHint}`
|
|
1295
2926
|
};
|
|
1296
2927
|
return JSON.stringify(result);
|
|
1297
2928
|
}
|
|
1298
|
-
|
|
2929
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
2930
|
+
let text = new TextDecoder().decode(response.body);
|
|
1299
2931
|
if (contentType.includes("html")) text = htmlToText(text);
|
|
1300
2932
|
return JSON.stringify({
|
|
1301
2933
|
success: true,
|
|
@@ -1310,64 +2942,88 @@ async function runWebFetch(args, signal) {
|
|
|
1310
2942
|
return JSON.stringify(result);
|
|
1311
2943
|
}
|
|
1312
2944
|
}
|
|
1313
|
-
const
|
|
2945
|
+
const DEFAULT_WEB_FETCH_DESCRIPTION = "Fetch a URL and return its content as text. HTML pages are converted to plain text.";
|
|
2946
|
+
/**
|
|
2947
|
+
* Create a WebFetchTool instance — register with Robota agent tools registry.
|
|
2948
|
+
*/
|
|
2949
|
+
function createWebFetchTool(options = {}) {
|
|
2950
|
+
const egress = options.egress ?? {};
|
|
2951
|
+
return createZodFunctionTool("WebFetch", options.description ?? DEFAULT_WEB_FETCH_DESCRIPTION, WebFetchSchema, async (params, context) => runWebFetch(params, egress, context?.signal));
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* WebFetchTool instance — register with Robota agent tools registry.
|
|
2955
|
+
*/
|
|
2956
|
+
const webFetchTool = createWebFetchTool();
|
|
2957
|
+
//#endregion
|
|
2958
|
+
//#region src/builtins/brave-search-provider.ts
|
|
2959
|
+
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
|
2960
|
+
/** Brave caps `count` at 20 per request. */
|
|
2961
|
+
const BRAVE_MAX_COUNT = 20;
|
|
2962
|
+
/**
|
|
2963
|
+
* Create the Brave Search provider. Throws from `search()` when `BRAVE_API_KEY` is not set or
|
|
2964
|
+
* the API responds with an error — the tool layer surfaces the message as a structured result.
|
|
2965
|
+
*/
|
|
2966
|
+
function createBraveSearchProvider() {
|
|
2967
|
+
return { async search({ query, limit }, signal) {
|
|
2968
|
+
const apiKey = process.env["BRAVE_API_KEY"];
|
|
2969
|
+
if (!apiKey) throw new Error("Web search requires BRAVE_API_KEY environment variable for the default Brave Search provider, or inject a custom search provider at the composition root.");
|
|
2970
|
+
const params = new URLSearchParams({
|
|
2971
|
+
q: query,
|
|
2972
|
+
count: String(Math.min(limit, BRAVE_MAX_COUNT))
|
|
2973
|
+
});
|
|
2974
|
+
const response = await fetch(`${BRAVE_SEARCH_ENDPOINT}?${params}`, {
|
|
2975
|
+
headers: {
|
|
2976
|
+
Accept: "application/json",
|
|
2977
|
+
"Accept-Encoding": "gzip",
|
|
2978
|
+
"X-Subscription-Token": apiKey
|
|
2979
|
+
},
|
|
2980
|
+
...signal ? { signal } : {}
|
|
2981
|
+
});
|
|
2982
|
+
if (!response.ok) throw new Error(`Brave Search API error: HTTP ${response.status} ${response.statusText}`);
|
|
2983
|
+
return ((await response.json()).web?.results ?? []).map((r) => ({
|
|
2984
|
+
title: r.title,
|
|
2985
|
+
url: r.url,
|
|
2986
|
+
snippet: r.description
|
|
2987
|
+
}));
|
|
2988
|
+
} };
|
|
2989
|
+
}
|
|
1314
2990
|
//#endregion
|
|
1315
2991
|
//#region src/builtins/web-search-tool.ts
|
|
1316
2992
|
/**
|
|
1317
2993
|
* WebSearchTool — search the web and return results.
|
|
1318
2994
|
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
2995
|
+
* Vendor-free tool layer (NEUT-008): composes over the duck-typed `IWebSearchProvider` port.
|
|
2996
|
+
* The default provider is the vendor-specific default adapter wired at creation time; a custom
|
|
2997
|
+
* provider is injected via `createWebSearchTool({ provider })`. Provider failures (missing
|
|
2998
|
+
* configuration, HTTP/network errors) are thrown by the provider and surfaced here as
|
|
2999
|
+
* structured error results.
|
|
1321
3000
|
*/
|
|
1322
3001
|
const DEFAULT_LIMIT = 10;
|
|
1323
3002
|
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
3003
|
+
const DEFAULT_WEB_SEARCH_DESCRIPTION = "Search the web and return results with title, URL, and snippet.";
|
|
1324
3004
|
const WebSearchSchema = z.object({
|
|
1325
3005
|
query: z.string().describe("The search query"),
|
|
1326
3006
|
limit: z.number().optional().describe(`Maximum number of results to return (default: ${DEFAULT_LIMIT})`)
|
|
1327
3007
|
});
|
|
1328
|
-
async function runWebSearch(args, signal) {
|
|
3008
|
+
async function runWebSearch(args, provider, signal) {
|
|
1329
3009
|
const { query, limit = DEFAULT_LIMIT } = args;
|
|
1330
|
-
const apiKey = process.env["BRAVE_API_KEY"];
|
|
1331
|
-
if (!apiKey) return JSON.stringify({
|
|
1332
|
-
success: false,
|
|
1333
|
-
output: "",
|
|
1334
|
-
error: "Web search requires BRAVE_API_KEY environment variable. Get a free API key at https://brave.com/search/api/ (2,000 queries/month free)."
|
|
1335
|
-
});
|
|
1336
3010
|
try {
|
|
1337
3011
|
const controller = new AbortController();
|
|
1338
3012
|
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
headers: {
|
|
1346
|
-
Accept: "application/json",
|
|
1347
|
-
"Accept-Encoding": "gzip",
|
|
1348
|
-
"X-Subscription-Token": apiKey
|
|
1349
|
-
},
|
|
1350
|
-
signal: fetchSignal
|
|
1351
|
-
});
|
|
1352
|
-
clearTimeout(timeout);
|
|
1353
|
-
if (!response.ok) {
|
|
3013
|
+
const searchSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
3014
|
+
try {
|
|
3015
|
+
const results = await provider.search({
|
|
3016
|
+
query,
|
|
3017
|
+
limit
|
|
3018
|
+
}, searchSignal);
|
|
1354
3019
|
const result = {
|
|
1355
|
-
success:
|
|
1356
|
-
output:
|
|
1357
|
-
error: `Brave Search API error: HTTP ${response.status} ${response.statusText}`
|
|
3020
|
+
success: true,
|
|
3021
|
+
output: JSON.stringify(results, null, 2)
|
|
1358
3022
|
};
|
|
1359
3023
|
return JSON.stringify(result);
|
|
3024
|
+
} finally {
|
|
3025
|
+
clearTimeout(timeout);
|
|
1360
3026
|
}
|
|
1361
|
-
const results = ((await response.json()).web?.results ?? []).map((r) => ({
|
|
1362
|
-
title: r.title,
|
|
1363
|
-
url: r.url,
|
|
1364
|
-
snippet: r.description
|
|
1365
|
-
}));
|
|
1366
|
-
const result = {
|
|
1367
|
-
success: true,
|
|
1368
|
-
output: JSON.stringify(results, null, 2)
|
|
1369
|
-
};
|
|
1370
|
-
return JSON.stringify(result);
|
|
1371
3027
|
} catch (err) {
|
|
1372
3028
|
const result = {
|
|
1373
3029
|
success: false,
|
|
@@ -1377,7 +3033,17 @@ async function runWebSearch(args, signal) {
|
|
|
1377
3033
|
return JSON.stringify(result);
|
|
1378
3034
|
}
|
|
1379
3035
|
}
|
|
1380
|
-
|
|
3036
|
+
/**
|
|
3037
|
+
* Create a WebSearchTool instance — register with Robota agent tools registry.
|
|
3038
|
+
*/
|
|
3039
|
+
function createWebSearchTool(options = {}) {
|
|
3040
|
+
const provider = options.provider ?? createBraveSearchProvider();
|
|
3041
|
+
return createZodFunctionTool("WebSearch", options.description ?? DEFAULT_WEB_SEARCH_DESCRIPTION, WebSearchSchema, async (params, context) => runWebSearch(params, provider, context?.signal));
|
|
3042
|
+
}
|
|
3043
|
+
/**
|
|
3044
|
+
* WebSearchTool instance — register with Robota agent tools registry.
|
|
3045
|
+
*/
|
|
3046
|
+
const webSearchTool = createWebSearchTool();
|
|
1381
3047
|
//#endregion
|
|
1382
3048
|
//#region src/builtins/ask-user-question-tool.ts
|
|
1383
3049
|
/**
|
|
@@ -1471,8 +3137,8 @@ async function askQuestions(args, ask) {
|
|
|
1471
3137
|
/**
|
|
1472
3138
|
* Create an `AskUserQuestion` tool instance — register with the Robota agent tools registry.
|
|
1473
3139
|
*/
|
|
1474
|
-
function createAskUserQuestionTool() {
|
|
1475
|
-
return createZodFunctionTool("AskUserQuestion", ASK_USER_QUESTION_DESCRIPTION, AskUserQuestionSchema, async (params, context) => {
|
|
3140
|
+
function createAskUserQuestionTool(options = {}) {
|
|
3141
|
+
return createZodFunctionTool("AskUserQuestion", options.description ?? ASK_USER_QUESTION_DESCRIPTION, AskUserQuestionSchema, async (params, context) => {
|
|
1476
3142
|
const args = params;
|
|
1477
3143
|
const ask = context?.ask;
|
|
1478
3144
|
const output = ask ? await askQuestions(args, ask) : {
|
|
@@ -1489,6 +3155,156 @@ function createAskUserQuestionTool() {
|
|
|
1489
3155
|
/** `AskUserQuestion` tool instance — register with the Robota agent tools registry. */
|
|
1490
3156
|
const askUserQuestionTool = createAskUserQuestionTool();
|
|
1491
3157
|
//#endregion
|
|
1492
|
-
|
|
3158
|
+
//#region src/builtins/tool-search-matching.ts
|
|
3159
|
+
/** Both vendors default a tool search to five results; so does this one. */
|
|
3160
|
+
const DEFAULT_TOOL_SEARCH_LIMIT = 5;
|
|
3161
|
+
/**
|
|
3162
|
+
* Where a query matched, lowest first — the primary sort key.
|
|
3163
|
+
*
|
|
3164
|
+
* A tool whose NAME the query names is a better answer than one that merely mentions it in a
|
|
3165
|
+
* parameter description, and saying so is what makes "the top five" meaningful once a catalog is
|
|
3166
|
+
* large enough for the limit to bite.
|
|
3167
|
+
*/
|
|
3168
|
+
const RANK_EXACT_NAME = 0;
|
|
3169
|
+
const RANK_NAME = 1;
|
|
3170
|
+
const RANK_DESCRIPTION = 2;
|
|
3171
|
+
const RANK_PARAMETER = 3;
|
|
3172
|
+
/** Not a match at all — filtered out rather than ranked last. */
|
|
3173
|
+
const RANK_NONE = Number.POSITIVE_INFINITY;
|
|
3174
|
+
/** Every parameter name and description in a schema, including nested nodes. */
|
|
3175
|
+
function collectParameterText(node, into) {
|
|
3176
|
+
if (node.description !== void 0) into.push(node.description);
|
|
3177
|
+
for (const [name, child] of Object.entries(node.properties ?? {})) {
|
|
3178
|
+
into.push(name);
|
|
3179
|
+
collectParameterText(child, into);
|
|
3180
|
+
}
|
|
3181
|
+
if (node.items !== void 0) collectParameterText(node.items, into);
|
|
3182
|
+
for (const branch of node.anyOf ?? []) collectParameterText(branch, into);
|
|
3183
|
+
}
|
|
3184
|
+
function rankMatch(schema, query) {
|
|
3185
|
+
const name = schema.name.toLowerCase();
|
|
3186
|
+
if (name === query) return RANK_EXACT_NAME;
|
|
3187
|
+
if (name.includes(query)) return RANK_NAME;
|
|
3188
|
+
if (schema.description.toLowerCase().includes(query)) return RANK_DESCRIPTION;
|
|
3189
|
+
const parameterText = [];
|
|
3190
|
+
collectParameterText(schema.parameters, parameterText);
|
|
3191
|
+
if (parameterText.some((text) => text.toLowerCase().includes(query))) return RANK_PARAMETER;
|
|
3192
|
+
return RANK_NONE;
|
|
3193
|
+
}
|
|
3194
|
+
/**
|
|
3195
|
+
* The tools a query selects, best match first and capped at `limit`.
|
|
3196
|
+
*
|
|
3197
|
+
* Ordering is total and deterministic: rank first, then name, so two tools that matched the same way
|
|
3198
|
+
* never trade places between calls. An empty query string matches nothing rather than everything —
|
|
3199
|
+
* "search for nothing" is a question with an empty answer, not a request for the whole catalog.
|
|
3200
|
+
*/
|
|
3201
|
+
function matchDeferredTools(schemas, query, limit) {
|
|
3202
|
+
const needle = query.trim().toLowerCase();
|
|
3203
|
+
if (needle.length === 0) return [];
|
|
3204
|
+
return schemas.map((schema) => ({
|
|
3205
|
+
schema,
|
|
3206
|
+
rank: rankMatch(schema, needle)
|
|
3207
|
+
})).filter((entry) => entry.rank !== RANK_NONE).sort((a, b) => a.rank - b.rank || (a.schema.name < b.schema.name ? -1 : 1)).slice(0, limit).map((entry) => entry.schema);
|
|
3208
|
+
}
|
|
3209
|
+
//#endregion
|
|
3210
|
+
//#region src/builtins/tool-search-tool.ts
|
|
3211
|
+
/**
|
|
3212
|
+
* ToolSearch — the model-facing half of client-side tool deferral (CLI-1990 § Solution 4).
|
|
3213
|
+
*
|
|
3214
|
+
* A tool that declares `deferLoading` is withheld from the request entirely while the tool-search
|
|
3215
|
+
* policy is engaged, so the model never sees its schema. This tool is how the model gets it back:
|
|
3216
|
+
* it searches the withheld catalog by query, or loads an exact list by name, and the runtime marks
|
|
3217
|
+
* the matches loaded — so the NEXT round's `tools` array carries their full definitions and they
|
|
3218
|
+
* stay callable for the rest of the session.
|
|
3219
|
+
*
|
|
3220
|
+
* Deliberately an ordinary function tool rather than a vendor block. Anthropic and OpenAI each ship
|
|
3221
|
+
* a server-side tool search, but neither reduces the request payload (the API needs every definition
|
|
3222
|
+
* to run the search), Gemini has no equivalent at all, and both vendors document a client-executed
|
|
3223
|
+
* search as the portable form. One shape therefore runs everywhere and saves both wire bytes and
|
|
3224
|
+
* context tokens.
|
|
3225
|
+
*
|
|
3226
|
+
* Two results are NOT errors, and the distinction is the contract:
|
|
3227
|
+
* - a query that matches nothing returns `{ loaded: [], unavailableSources: [] }` — a normal empty
|
|
3228
|
+
* answer, mirroring the vendor's own empty `tool_references` array;
|
|
3229
|
+
* - an unknown entry in `names` throws, naming the entry, and loads nothing — asking for a tool that
|
|
3230
|
+
* does not exist is a mistake to correct, not an empty search.
|
|
3231
|
+
*
|
|
3232
|
+
* `unavailableSources` is present and empty from day one. MCP-003 (the connection and capability
|
|
3233
|
+
* supervisor) fills it with servers that failed or need auth, so a model told "nothing matched" can
|
|
3234
|
+
* tell that apart from "the server holding it is down" — without a contract change here.
|
|
3235
|
+
*/
|
|
3236
|
+
/**
|
|
3237
|
+
* The registered name — agent-core's own constant, re-exported under this package's name so the
|
|
3238
|
+
* execution layer's unknown-tool remedy and this tool can never name two different things.
|
|
3239
|
+
*/
|
|
3240
|
+
const TOOL_SEARCH_NAME = TOOL_SEARCH_TOOL_NAME;
|
|
3241
|
+
const ToolSearchSchema = z.object({
|
|
3242
|
+
query: z.string().optional().describe("Text matched case-insensitively against each withheld tool's name, description, and its parameters' names and descriptions. An exact tool name is a valid query."),
|
|
3243
|
+
names: z.array(z.string().min(1)).optional().describe("Exact tool names to load, skipping the search. An unknown name is an error naming it."),
|
|
3244
|
+
limit: z.number().int().positive().optional().describe(`Maximum tools to load from a query match (default 5).`)
|
|
3245
|
+
});
|
|
3246
|
+
const TOOL_SEARCH_DESCRIPTION = [
|
|
3247
|
+
"Load tools whose definitions are withheld from your tool list, so you can call them.",
|
|
3248
|
+
"",
|
|
3249
|
+
"Some tools are deferred: they exist and are callable, but their schemas are not sent to you until",
|
|
3250
|
+
"you load them here. If a capability you need is not in your tool list, search for it before",
|
|
3251
|
+
"concluding it is unavailable — and if a tool call fails as \"deferred and not yet loaded\", load it",
|
|
3252
|
+
"with this tool and call it again.",
|
|
3253
|
+
"",
|
|
3254
|
+
" - query: what you are trying to do (e.g. \"read a spreadsheet\", \"postgres\"). Matched against tool",
|
|
3255
|
+
" names, descriptions, and parameter names and descriptions. An exact tool name works too.",
|
|
3256
|
+
` - names: load exactly these tools, skipping the search. Unknown names are an error.`,
|
|
3257
|
+
` - limit: how many matches to load (default 5).`,
|
|
3258
|
+
"",
|
|
3259
|
+
"The result lists what is now loaded; those tools appear in your tool list from your next turn and",
|
|
3260
|
+
"stay available. A query that matches nothing returns an empty list — that is a normal answer, not",
|
|
3261
|
+
"a failure. `unavailableSources` names any tool source that could not be consulted."
|
|
3262
|
+
].join("\n");
|
|
3263
|
+
/**
|
|
3264
|
+
* The catalog the runtime injects, or a thrown wiring error.
|
|
3265
|
+
*
|
|
3266
|
+
* Its absence is not a runtime condition to degrade around: `ToolExecutionService` attaches this
|
|
3267
|
+
* port to every tool call it issues, so a missing one means this tool was invoked outside the
|
|
3268
|
+
* execution loop. Guessing an empty catalog there would report "nothing matched" for a search that
|
|
3269
|
+
* was never actually run.
|
|
3270
|
+
*/
|
|
3271
|
+
function requireCatalog(context) {
|
|
3272
|
+
const catalog = context?.deferredTools;
|
|
3273
|
+
if (!catalog) throw new Error(`${TOOL_SEARCH_NAME} requires the deferred-tool catalog, which the execution runtime injects; it was not present, so this tool was called outside the agent execution loop.`);
|
|
3274
|
+
return catalog;
|
|
3275
|
+
}
|
|
3276
|
+
/** Which schemas this call loads: the exact `names`, else the query's ranked matches. */
|
|
3277
|
+
function selectTools(args, catalog) {
|
|
3278
|
+
if (args.names !== void 0) return catalog.loadDeferredTools(args.names);
|
|
3279
|
+
if (args.query === void 0) throw new Error(`${TOOL_SEARCH_NAME} needs either "query" to search for tools or "names" to load exact ones.`);
|
|
3280
|
+
const matches = matchDeferredTools(catalog.listDeferredTools(), args.query, args.limit ?? 5);
|
|
3281
|
+
return catalog.loadDeferredTools(matches.map((schema) => schema.name));
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* Create a `ToolSearch` tool instance — register it RESIDENT with the agent's tool registry.
|
|
3285
|
+
*
|
|
3286
|
+
* It must never itself be deferred: a search tool the model cannot see is a catalog with no way in,
|
|
3287
|
+
* which is the state the vendor's own "at least one tool must stay resident" invariant forbids.
|
|
3288
|
+
*/
|
|
3289
|
+
function createToolSearchTool(options = {}) {
|
|
3290
|
+
return createZodFunctionTool(TOOL_SEARCH_NAME, options.description ?? TOOL_SEARCH_DESCRIPTION, ToolSearchSchema, async (params, context) => {
|
|
3291
|
+
const output = {
|
|
3292
|
+
loaded: selectTools(params, requireCatalog(context)).map(({ name, description }) => ({
|
|
3293
|
+
name,
|
|
3294
|
+
description
|
|
3295
|
+
})),
|
|
3296
|
+
unavailableSources: []
|
|
3297
|
+
};
|
|
3298
|
+
const result = {
|
|
3299
|
+
success: true,
|
|
3300
|
+
output: JSON.stringify(output)
|
|
3301
|
+
};
|
|
3302
|
+
return JSON.stringify(result);
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
/** `ToolSearch` tool instance — register with the Robota agent tools registry. */
|
|
3306
|
+
const toolSearchTool = createToolSearchTool();
|
|
3307
|
+
//#endregion
|
|
3308
|
+
export { DEFAULT_OS_SANDBOX_SETTINGS, DEFAULT_TOOL_SEARCH_LIMIT, E2BSandboxClient, GrepIsolationError, InMemorySandboxClient, OsSandboxClient, PageComputerDriver, REPO_MAP_INDEX_VERSION, ReadByteLimitError, ReadCancelledError, RepoMapRetrievalAdapter, TOOL_SEARCH_NAME, applyWorkspaceManifest, askUserQuestionTool, bubblewrapArguments, buildRepoMapIndex, createAskUserQuestionTool, createBashTool, createBraveSearchProvider, createComputerActTool, createComputerTool, createComputerViewTool, createEditTool, createFunctionTool, createGlobTool, createGrepTool, createReadTool, createRetrievalTool, createShellTool, createToolSearchTool, createWebFetchTool, createWebSearchTool, createWriteTool, createZodFunctionTool, describeExecutionContainment, deserializeRepoMapIndex, detectOsSandbox, matchDeferredTools, protectedWorkspaceEntries, routesFilesThroughSandbox, seatbeltProfile, serializeRepoMapIndex, toolSearchTool, updateRepoMapIndex, validateWorkspaceManifestPath, webFetchTool, webSearchTool };
|
|
1493
3309
|
|
|
1494
3310
|
//# sourceMappingURL=index.js.map
|