@zocomputer/agent-sdk 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
package/src/run.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { createBoundedCapture, type BoundedCapture } from "./bounded-output";
4
+ import type { Workspace } from "./workspace";
5
+
6
+ // Tool results enter the transcript permanently, so a full-run result is
7
+ // bounded to head + tail (see bounded-output.ts) with the complete output
8
+ // spilled to the runner's spill dir — the model greps/reads the spill instead
9
+ // of re-running the command. Live progress previews keep just the tail (the
10
+ // latest lines matter while it's still running).
11
+ export const MAX_PREVIEW = 20_000;
12
+
13
+ export interface RunResult {
14
+ stdout: string;
15
+ stderr: string;
16
+ exitCode: number | null;
17
+ timedOut: boolean;
18
+ }
19
+
20
+ export interface RunProgress {
21
+ stdout: string;
22
+ stderr: string;
23
+ stdoutBytes: number;
24
+ stderrBytes: number;
25
+ stdoutTruncated: boolean;
26
+ stderrTruncated: boolean;
27
+ }
28
+
29
+ export interface RunningCommand {
30
+ result: Promise<RunResult>;
31
+ progress(): RunProgress;
32
+ kill(): void;
33
+ }
34
+
35
+ export interface StartCommandOptions {
36
+ cwd?: string | undefined;
37
+ timeoutMs?: number;
38
+ /**
39
+ * Raw output tap, called with every stdout/stderr chunk as it arrives
40
+ * (before any preview truncation). Powers background-output watchers.
41
+ */
42
+ onOutput?: ((chunk: string) => void) | undefined;
43
+ }
44
+
45
+ export interface CommandRunner {
46
+ /** Spawn a shell command and return live handles (progress preview, kill, result). */
47
+ startCommand(command: string, opts?: StartCommandOptions): RunningCommand;
48
+ /** startCommand, awaited to completion. */
49
+ runCommand(command: string, opts?: StartCommandOptions): Promise<RunResult>;
50
+ }
51
+
52
+ function previewOf(capture: BoundedCapture): string {
53
+ const latest = capture.latest();
54
+ if (capture.totalChars() <= MAX_PREVIEW) return latest;
55
+ return `… [earlier output truncated]\n${latest.slice(-MAX_PREVIEW)}`;
56
+ }
57
+
58
+ // Commands run rooted at the workspace (cwd resolved within it — a real shell
59
+ // otherwise: no sandbox, no undo). Overflowing output spills to `spillDir`,
60
+ // labeled workspace-relative so the model can read/grep the file.
61
+ export function createCommandRunner(opts: {
62
+ workspace: Workspace;
63
+ spillDir: string;
64
+ }): CommandRunner {
65
+ const { workspace, spillDir } = opts;
66
+
67
+ function startCommand(command: string, runOpts: StartCommandOptions = {}): RunningCommand {
68
+ const cwd = runOpts.cwd ? workspace.resolve(runOpts.cwd) : workspace.root;
69
+ const timeoutMs = runOpts.timeoutMs ?? 120_000;
70
+ // detached: the shell gets its own process group, so kills can target the
71
+ // whole tree — killing just the shell leaves grandchildren running AND
72
+ // holding the stdio pipes, which stalls `close` (and the result promise)
73
+ // until they exit on their own.
74
+ const child = spawn(command, { cwd, shell: true, env: process.env, detached: true });
75
+ // One spill file per stream per invocation; created only on overflow.
76
+ const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
77
+ const captureFor = (stream: "stdout" | "stderr"): BoundedCapture => {
78
+ const spillPath = join(spillDir, `bash-${runId}-${stream}.log`);
79
+ return createBoundedCapture({ spillPath, spillLabel: workspace.relativize(spillPath) });
80
+ };
81
+ const stdoutCapture = captureFor("stdout");
82
+ const stderrCapture = captureFor("stderr");
83
+ let stdoutBytes = 0;
84
+ let stderrBytes = 0;
85
+ let timedOut = false;
86
+ let closed = false;
87
+ const killTree = (signal: NodeJS.Signals) => {
88
+ const pid = child.pid;
89
+ if (pid === undefined) return; // spawn failed; the "error" event resolves the result
90
+ try {
91
+ process.kill(-pid, signal); // negative pid = the detached process group
92
+ } catch {
93
+ child.kill(signal); // group already gone or not ours; fall back to the shell
94
+ }
95
+ };
96
+ const timer = setTimeout(() => {
97
+ timedOut = true;
98
+ killTree("SIGKILL");
99
+ }, timeoutMs);
100
+
101
+ const result = new Promise<RunResult>((resolvePromise) => {
102
+ child.stdout.on("data", (d: Buffer) => {
103
+ const chunk = d.toString();
104
+ stdoutBytes += Buffer.byteLength(chunk);
105
+ stdoutCapture.append(chunk);
106
+ runOpts.onOutput?.(chunk);
107
+ });
108
+ child.stderr.on("data", (d: Buffer) => {
109
+ const chunk = d.toString();
110
+ stderrBytes += Buffer.byteLength(chunk);
111
+ stderrCapture.append(chunk);
112
+ runOpts.onOutput?.(chunk);
113
+ });
114
+ child.on("close", (code) => {
115
+ closed = true;
116
+ clearTimeout(timer);
117
+ resolvePromise({
118
+ stdout: stdoutCapture.snapshot().text,
119
+ stderr: stderrCapture.snapshot().text,
120
+ exitCode: code,
121
+ timedOut,
122
+ });
123
+ });
124
+ child.on("error", (err: Error) => {
125
+ closed = true;
126
+ clearTimeout(timer);
127
+ resolvePromise({
128
+ stdout: stdoutCapture.snapshot().text,
129
+ stderr: `${stderrCapture.snapshot().text}${err.message}`,
130
+ exitCode: null,
131
+ timedOut,
132
+ });
133
+ });
134
+ });
135
+
136
+ return {
137
+ result,
138
+ progress() {
139
+ return {
140
+ stdout: previewOf(stdoutCapture),
141
+ stderr: previewOf(stderrCapture),
142
+ stdoutBytes,
143
+ stderrBytes,
144
+ stdoutTruncated: stdoutCapture.totalChars() > MAX_PREVIEW,
145
+ stderrTruncated: stderrCapture.totalChars() > MAX_PREVIEW,
146
+ };
147
+ },
148
+ kill() {
149
+ if (closed) return;
150
+ killTree("SIGTERM");
151
+ },
152
+ };
153
+ }
154
+
155
+ return {
156
+ startCommand,
157
+ runCommand: (command, runOpts) => startCommand(command, runOpts).result,
158
+ };
159
+ }
@@ -0,0 +1,414 @@
1
+ import ignore from "ignore";
2
+ import { globToRegExp } from "./glob-match";
3
+ import { MAX_SEARCH_FILE_BYTES } from "./read-text";
4
+ import { ALWAYS_IGNORED } from "./walk";
5
+ import { relativizeWithin } from "./workspace";
6
+ import type {
7
+ IoSearchMatch,
8
+ IoSearchOptions,
9
+ IoSearchResult,
10
+ IoStat,
11
+ IoToolContext,
12
+ SandboxSessionLike,
13
+ WorkspaceIO,
14
+ WorkspaceIoProvider,
15
+ } from "./workspace-io";
16
+
17
+ // The sandbox backend for the WorkspaceIO seam (see ./workspace-io.ts): the
18
+ // same read/edit/write/glob/grep factories, but every effect goes through an
19
+ // eve `SandboxSession` — for the hosted topology where the eve process (a
20
+ // Vercel Function) and the workspace (a Daytona VM behind SSH) are different
21
+ // machines, so `node:fs` would read the wrong disk entirely.
22
+ //
23
+ // Byte I/O rides the session's `readBinaryFile`/`writeBinaryFile` (the same
24
+ // primitives eve's attachment staging uses); stat/list/search execute
25
+ // remotely via `run` so a search never pulls file contents over the wire.
26
+ // The session arrives per tool call (`ctx.getSandbox()`), so the provider
27
+ // builds one lazily-bound IO per call — constructing it touches nothing.
28
+ //
29
+ // Search-pattern caveat: the grep tool validates patterns as JavaScript
30
+ // regexes, but a sandbox search executes them with ripgrep (Rust regex) or,
31
+ // when rg is absent, POSIX `grep -E`. The common core (literals, classes,
32
+ // anchors, quantifiers, alternation) behaves identically; exotic JS features
33
+ // (lookbehind, \d in POSIX grep) may not. eve's built-in sandbox grep makes
34
+ // the same trade.
35
+
36
+ /** POSIX single-quoting for splicing untrusted strings into a shell command. */
37
+ export function shellSingleQuote(value: string): string {
38
+ return `'${value.replaceAll("'", `'\\''`)}'`;
39
+ }
40
+
41
+ export interface SandboxIoOptions {
42
+ /**
43
+ * Absolute path of the workspace root **inside the sandbox** (e.g.
44
+ * "/workspace", or the Builder's "/home/daytona/agent"). Must match the
45
+ * root the tools' `Workspace` was created with.
46
+ */
47
+ root: string;
48
+ /**
49
+ * Resolves the sandbox session for one tool call. Defaults to
50
+ * `ctx.getSandbox()` — the eve session sandbox. Injectable for tests and
51
+ * for callers that hold a session some other way.
52
+ */
53
+ resolveSession?: (
54
+ ctx: IoToolContext | undefined,
55
+ ) => PromiseLike<SandboxSessionLike>;
56
+ }
57
+
58
+ function defaultResolveSession(
59
+ ctx: IoToolContext | undefined,
60
+ ): PromiseLike<SandboxSessionLike> {
61
+ if (ctx === undefined) {
62
+ throw new Error(
63
+ "Sandbox-backed workspace tools need an eve tool context (ctx.getSandbox); none was provided.",
64
+ );
65
+ }
66
+ return ctx.getSandbox();
67
+ }
68
+
69
+ /**
70
+ * A `WorkspaceIoProvider` over the session sandbox — pass as the `io` option
71
+ * of the file-tool factories (or use `createSandboxFileTools`, which wires
72
+ * the whole set).
73
+ */
74
+ export function sandboxIoProvider(options: SandboxIoOptions): WorkspaceIoProvider {
75
+ const resolve = options.resolveSession ?? defaultResolveSession;
76
+ return (ctx) =>
77
+ createSandboxIo({ root: options.root, session: () => resolve(ctx) });
78
+ }
79
+
80
+ /**
81
+ * One call's IO over a sandbox session. The session resolves lazily on first
82
+ * use and is shared across the call's operations.
83
+ */
84
+ export function createSandboxIo(opts: {
85
+ root: string;
86
+ session: () => PromiseLike<SandboxSessionLike>;
87
+ }): WorkspaceIO {
88
+ const { root } = opts;
89
+ let resolved: Promise<SandboxSessionLike> | null = null;
90
+ const session = (): Promise<SandboxSessionLike> => {
91
+ resolved ??= Promise.resolve(opts.session());
92
+ return resolved;
93
+ };
94
+
95
+ async function run(command: string): Promise<{
96
+ exitCode: number;
97
+ stdout: string;
98
+ stderr: string;
99
+ }> {
100
+ const sb = await session();
101
+ return await sb.run({ command, workingDirectory: root });
102
+ }
103
+
104
+ return {
105
+ async stat(abs): Promise<IoStat | null> {
106
+ // GNU coreutils format first (every Linux sandbox); the BSD fallback
107
+ // only runs when -c itself failed (a macOS-hosted fake in tests). A
108
+ // missing path fails both branches → null.
109
+ const q = shellSingleQuote(abs);
110
+ const result = await run(
111
+ `stat -c '%s %Y %F' -- ${q} 2>/dev/null || stat -f '%z %m %HT' -- ${q}`,
112
+ );
113
+ if (result.exitCode !== 0) return null;
114
+ const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(result.stdout.trim());
115
+ if (match === null) return null;
116
+ const [, size, mtimeSec, kind] = match;
117
+ if (size === undefined || mtimeSec === undefined || kind === undefined) return null;
118
+ return {
119
+ isFile: /regular/i.test(kind),
120
+ size: Number(size),
121
+ mtimeMs: Number(mtimeSec) * 1000,
122
+ };
123
+ },
124
+
125
+ async readFile(abs) {
126
+ const sb = await session();
127
+ const bytes = await sb.readBinaryFile({ path: abs });
128
+ return bytes === null ? null : Buffer.from(bytes);
129
+ },
130
+
131
+ async writeFile(abs, content) {
132
+ const sb = await session();
133
+ const bytes =
134
+ typeof content === "string" ? new TextEncoder().encode(content) : content;
135
+ // Parent directories are the backend's job (the AI SDK sandbox write
136
+ // contract creates them recursively).
137
+ await sb.writeBinaryFile({ path: abs, content: bytes });
138
+ },
139
+
140
+ async listFiles(scope) {
141
+ const rel = scope === undefined ? undefined : relativizeWithin(root, scope);
142
+ const spec = rel === undefined || rel === "." ? "" : ` -- ${shellSingleQuote(rel)}`;
143
+ const listed = await run(
144
+ `git ls-files --cached --others --exclude-standard -z${spec}`,
145
+ );
146
+ if (listed.exitCode === 0) {
147
+ const files = listed.stdout.split("\0").filter((p) => p.length > 0);
148
+ // `--cached` still lists a tracked file after an un-staged `rm`;
149
+ // subtract those, tolerating a failed follow-up (see ./list-files.ts).
150
+ const deleted = await run(`git ls-files --deleted -z${spec}`);
151
+ if (deleted.exitCode !== 0) return files;
152
+ const gone = new Set(deleted.stdout.split("\0").filter((p) => p.length > 0));
153
+ return gone.size === 0 ? files : files.filter((p) => !gone.has(p));
154
+ }
155
+ // Not a git checkout (or git missing): a pruned find (VCS stores +
156
+ // node_modules), then the root .gitignore applied client-side —
157
+ // without git to interpret the full nested-ignore chain, the root
158
+ // file covers the common build-output patterns the local walk skips.
159
+ const start = rel === undefined || rel === "." ? "." : shellSingleQuote(rel);
160
+ const prune = `\\( ${[...ALWAYS_IGNORED].map((dir) => `-name ${dir}`).join(" -o ")} \\) -prune`;
161
+ const found = await run(`find ${start} ${prune} -o -type f -print`);
162
+ if (found.exitCode !== 0) {
163
+ throw new Error(
164
+ `Could not list workspace files in the sandbox: ${found.stderr.trim() || `find exited ${found.exitCode}`}`,
165
+ );
166
+ }
167
+ const files = found.stdout
168
+ .split("\n")
169
+ .map((line) => (line.startsWith("./") ? line.slice(2) : line))
170
+ .filter((line) => line.length > 0);
171
+ const sb = await session();
172
+ const rootIgnore = await sb.readBinaryFile({ path: `${root}/.gitignore` });
173
+ if (rootIgnore === null) return files;
174
+ const matcher = ignore().add(Buffer.from(rootIgnore).toString("utf8"));
175
+ return files.filter((file) => !matcher.ignores(file));
176
+ },
177
+
178
+ async search(options) {
179
+ const scopeRel =
180
+ options.scope === undefined ? "." : relativizeWithin(root, options.scope);
181
+ const viaRg = await runSearch(run, buildRipgrepCommand(options, scopeRel));
182
+ if (viaRg.exitCode === 0 || viaRg.exitCode === 1) {
183
+ return parseSearchOutput(viaRg.stdout, options.maxMatches, viaRg.flooded);
184
+ }
185
+ // 127 = rg not installed; anything else from rg is a real error.
186
+ if (viaRg.exitCode !== 127) {
187
+ throw new Error(
188
+ `Search failed in the sandbox (rg exited ${viaRg.exitCode}): ${viaRg.stderr.trim()}`,
189
+ );
190
+ }
191
+ const viaGrep = await runSearch(run, buildPosixGrepCommand(options, scopeRel));
192
+ if (viaGrep.exitCode === 0 || viaGrep.exitCode === 1) {
193
+ // The find|grep pipeline has neither gitignore awareness nor the
194
+ // local backend's glob semantics, so both filters run client-side on
195
+ // the parsed matches — the glob through the same globToRegExp the
196
+ // local backend uses (path globs like `src/**/*.ts` included), the
197
+ // ignore rules from the root .gitignore. Filter BEFORE the match
198
+ // cap, so filtered lines never consume the budget (stdout is
199
+ // already bounded by runSearch's byte cap).
200
+ const parsed = parseSearchOutput(
201
+ viaGrep.stdout,
202
+ Number.MAX_SAFE_INTEGER,
203
+ viaGrep.flooded,
204
+ );
205
+ const globRe = options.glob === undefined ? null : globToRegExp(options.glob);
206
+ const sb = await session();
207
+ const rootIgnore = await sb.readBinaryFile({ path: `${root}/.gitignore` });
208
+ const matcher =
209
+ rootIgnore === null
210
+ ? null
211
+ : ignore().add(Buffer.from(rootIgnore).toString("utf8"));
212
+ const kept = parsed.matches.filter(
213
+ (m) =>
214
+ (globRe === null || globRe.test(m.file)) &&
215
+ (matcher === null || !matcher.ignores(m.file)),
216
+ );
217
+ return {
218
+ matches: kept.slice(0, options.maxMatches),
219
+ // Flood keeps precedence — a cut stream is the stronger claim.
220
+ stopped:
221
+ parsed.stopped === "output-cap"
222
+ ? "output-cap"
223
+ : kept.length >= options.maxMatches
224
+ ? "max-matches"
225
+ : parsed.stopped,
226
+ skippedLargeFiles: null,
227
+ };
228
+ }
229
+ throw new Error(
230
+ `Search failed in the sandbox (grep exited ${viaGrep.exitCode}): ${viaGrep.stderr.trim()}`,
231
+ );
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Total-output cap on a remote search. `--max-count` only bounds matches per
238
+ * FILE, so many matching files could otherwise stream an unbounded stdout
239
+ * over the sandbox transport before the parser truncates. 10 MiB holds far
240
+ * more lines than any match cap needs while bounding the transfer.
241
+ */
242
+ export const SEARCH_OUTPUT_CAP_BYTES = 10 * 1024 * 1024;
243
+
244
+ const SEARCH_EXIT_SENTINEL = "__ZO_SEARCH_EXIT__";
245
+
246
+ /**
247
+ * Run a search command with its stdout piped through `head -c`. The pipe
248
+ * makes the shell's exit code head's (useless), so the search's own exit
249
+ * code rides stdout as a trailing sentinel line. A missing sentinel means
250
+ * head cut the output — the search flooded the cap — which the caller
251
+ * treats as a truncated-but-successful scan.
252
+ */
253
+ async function runSearch(
254
+ run: (command: string) => Promise<{ exitCode: number; stdout: string; stderr: string }>,
255
+ command: string,
256
+ ): Promise<{ exitCode: number; stdout: string; stderr: string; flooded: boolean }> {
257
+ const wrapped = `{ ${command}; printf '\\n${SEARCH_EXIT_SENTINEL}:%d\\n' "$?"; } | head -c ${SEARCH_OUTPUT_CAP_BYTES}`;
258
+ const result = await run(wrapped);
259
+ const parsed = extractSearchExit(result.stdout);
260
+ if (parsed.exitCode === null) {
261
+ // No sentinel and the shell itself failed: a real execution error.
262
+ if (result.exitCode !== 0) {
263
+ return { exitCode: result.exitCode, stdout: parsed.stdout, stderr: result.stderr, flooded: false };
264
+ }
265
+ // No sentinel with a clean shell exit: head cut the stream mid-flood.
266
+ return { exitCode: 0, stdout: parsed.stdout, stderr: result.stderr, flooded: true };
267
+ }
268
+ return {
269
+ exitCode: parsed.exitCode,
270
+ stdout: parsed.stdout,
271
+ stderr: result.stderr,
272
+ flooded: false,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Split a search's stdout from its trailing exit sentinel. Exported for
278
+ * tests. `exitCode: null` = sentinel missing (output was cut by the cap, or
279
+ * the shell never ran the wrapped block).
280
+ */
281
+ export function extractSearchExit(stdout: string): {
282
+ stdout: string;
283
+ exitCode: number | null;
284
+ } {
285
+ const match = new RegExp(`\\n?${SEARCH_EXIT_SENTINEL}:(\\d+)\\n?$`).exec(stdout);
286
+ if (match === null || match[1] === undefined) {
287
+ return { stdout, exitCode: null };
288
+ }
289
+ return { stdout: stdout.slice(0, match.index), exitCode: Number(match[1]) };
290
+ }
291
+
292
+ // `--max-count` bounds matches per file; the total bound is enforced when
293
+ // parsing (parseSearchOutput) plus the byte cap in runSearch, same split as
294
+ // eve's built-in sandbox grep. `--max-filesize` mirrors the local backend's
295
+ // oversized-file skip (MAX_SEARCH_FILE_BYTES) so the tool description's
296
+ // size claim holds on this backend too. The `!**/<dir>` globs mirror the
297
+ // local walk's unconditional skips (walk.ts ALWAYS_IGNORED) — rg honors
298
+ // .gitignore on its own, but a non-git tree (or an uncovered node_modules)
299
+ // must not diverge from the local backend by flooding into dependency dirs.
300
+ function buildRipgrepCommand(options: IoSearchOptions, scopeRel: string): string {
301
+ const parts = [
302
+ "rg",
303
+ "--line-number",
304
+ "--with-filename",
305
+ "--no-heading",
306
+ "--color=never",
307
+ "--hidden",
308
+ ...[...ALWAYS_IGNORED].map((dir) => `--glob ${shellSingleQuote(`!**/${dir}`)}`),
309
+ `--max-filesize ${MAX_SEARCH_FILE_BYTES}`,
310
+ ];
311
+ if (options.ignoreCase) parts.push("--ignore-case");
312
+ if (options.glob !== undefined) parts.push(`--glob ${shellSingleQuote(options.glob)}`);
313
+ parts.push(`--max-count ${options.maxMatches}`);
314
+ parts.push(`--regexp ${shellSingleQuote(options.pattern)}`);
315
+ parts.push("--", shellSingleQuote(scopeRel));
316
+ return parts.join(" ");
317
+ }
318
+
319
+ // The last-resort searcher (no rg on the sandbox): a pruned, size-filtered
320
+ // `find` driving `grep` via `-exec … +`, so the VCS/dependency dirs and the
321
+ // oversized-file skip hold here too (POSIX grep alone has neither). The
322
+ // caller applies the root .gitignore to the matches, and the byte cap in
323
+ // runSearch bounds the transfer. One trade: find folds grep's exit codes
324
+ // into its own nonzero status, so a rare grep-side pattern error reads as
325
+ // "no matches" instead of throwing — acceptable for the last-resort path
326
+ // (patterns are pre-validated as JS regexes by the tool).
327
+ function buildPosixGrepCommand(options: IoSearchOptions, scopeRel: string): string {
328
+ const prune = `\\( ${[...ALWAYS_IGNORED].map((dir) => `-name ${dir}`).join(" -o ")} \\) -prune`;
329
+ // No `-name` filter for the glob: find's basename matching can't express
330
+ // path globs (`src/**/*.ts`), so the caller filters matches client-side
331
+ // with the local backend's globToRegExp instead.
332
+ const filters = ["-type f", `-size -${MAX_SEARCH_FILE_BYTES}c`];
333
+ const grep = [
334
+ "grep",
335
+ "-n",
336
+ "-H",
337
+ "-E",
338
+ "--color=never",
339
+ ...(options.ignoreCase ? ["-i"] : []),
340
+ `-m ${options.maxMatches}`,
341
+ "-e",
342
+ shellSingleQuote(options.pattern),
343
+ "--",
344
+ ].join(" ");
345
+ return `find ${shellSingleQuote(scopeRel)} ${prune} -o ${filters.join(" ")} -exec ${grep} {} +`;
346
+ }
347
+
348
+ /**
349
+ * Split one search line into `file:line:text` at the first `:<digits>:`
350
+ * boundary, or null for a non-match line (context separator, binary notice).
351
+ *
352
+ * A linear scan, deliberately not a regex: the natural `/^(.+?):(\d+):(.*)$/`
353
+ * is polynomial on a line that can't match (CodeQL js/polynomial-redos) —
354
+ * JS `.` excludes `\r`, so a CRLF line's trailing `\r` fails the final `$`
355
+ * and the lazy `.+?`/`\d+` backtrack quadratically (a crafted line near
356
+ * runSearch's 10 MB cap ran for minutes). Trimming the `\r` first also fixes
357
+ * the latent bug where every CRLF-file match silently parsed as null.
358
+ */
359
+ function parseSearchLine(
360
+ line: string,
361
+ ): { file: string; lineNo: string; text: string } | null {
362
+ const s = line.endsWith("\r") ? line.slice(0, -1) : line;
363
+ let from = 0;
364
+ for (;;) {
365
+ const colon = s.indexOf(":", from);
366
+ if (colon === -1) return null;
367
+ // file (the pre-regex `.+?`) must be non-empty.
368
+ if (colon === 0) {
369
+ from = 1;
370
+ continue;
371
+ }
372
+ let end = colon + 1;
373
+ while (end < s.length && s.charCodeAt(end) >= 48 && s.charCodeAt(end) <= 57) end++;
374
+ if (end > colon + 1 && s.charCodeAt(end) === 58 /* ":" */) {
375
+ return { file: s.slice(0, colon), lineNo: s.slice(colon + 1, end), text: s.slice(end + 1) };
376
+ }
377
+ from = colon + 1;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Parse `file:line:text` search output into matches, enforcing the total
383
+ * bound. Reaching the bound (or a `flooded` byte-capped stream) marks the
384
+ * scan stopped — more matches may exist past the cap, exactly like the
385
+ * local backend stopping its scan.
386
+ */
387
+ export function parseSearchOutput(
388
+ stdout: string,
389
+ maxMatches: number,
390
+ flooded = false,
391
+ ): IoSearchResult {
392
+ const matches: IoSearchMatch[] = [];
393
+ let stopped: IoSearchResult["stopped"] = flooded ? "output-cap" : false;
394
+ for (const line of stdout.split("\n")) {
395
+ if (line.length === 0) continue;
396
+ const parsed = parseSearchLine(line);
397
+ if (parsed === null) continue; // context separators, binary-file notices
398
+ const { file, lineNo, text } = parsed;
399
+ matches.push({
400
+ file: file.startsWith("./") ? file.slice(2) : file,
401
+ line: Number(lineNo),
402
+ text,
403
+ });
404
+ if (matches.length >= maxMatches) {
405
+ // A flood is the stronger claim: the stream was cut, so "stopped at
406
+ // the match cap" would overstate how much of the corpus was covered.
407
+ if (stopped === false) stopped = "max-matches";
408
+ break;
409
+ }
410
+ }
411
+ // Remote searchers enforce the size cap (rg --max-filesize) but don't
412
+ // report a skip count; null = unknown, never a misleading 0.
413
+ return { matches, stopped, skippedLargeFiles: null };
414
+ }