@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.
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/attachments.js +52 -0
- package/dist/gateway-fetch.js +67 -0
- package/dist/index.js +4169 -0
- package/dist/initiator-auth.js +49 -0
- package/dist/platform/agent-sandbox/index.js +691 -0
- package/dist/platform/cloud-tools/image.js +498 -0
- package/dist/platform/cloud-tools/index.js +507 -0
- package/dist/platform/cloud-tools/web-search.js +87 -0
- package/dist/platform/runtime-ai/gateway.js +87 -0
- package/dist/platform/runtime-ai/index.js +91 -0
- package/dist/platform/runtime-ai/register.js +88 -0
- package/dist/platform/runtime-ai/session-fetch.js +54 -0
- package/dist/platform/runtime-auth/index.js +141 -0
- package/dist/state-files.js +287 -0
- package/dist/state-sandbox.js +383 -0
- package/dist/state.js +29 -0
- package/dist/steer-inbox.js +84 -0
- package/dist/steer.js +83 -0
- package/package.json +143 -0
- package/platform/agent-sandbox/api-client.ts +196 -0
- package/platform/agent-sandbox/index.ts +27 -0
- package/platform/agent-sandbox/pure.ts +83 -0
- package/platform/agent-sandbox/sftp.ts +141 -0
- package/platform/agent-sandbox/ssh-connection.ts +165 -0
- package/platform/agent-sandbox/ssh-exec.ts +98 -0
- package/platform/agent-sandbox/ssh-session.ts +487 -0
- package/platform/agent-sandbox/zo-backend.ts +88 -0
- package/platform/agent-sandbox/zo-sandbox.ts +39 -0
- package/platform/cloud-tools/image-path.ts +44 -0
- package/platform/cloud-tools/image.ts +225 -0
- package/platform/cloud-tools/index.ts +22 -0
- package/platform/cloud-tools/state-files.ts +368 -0
- package/platform/cloud-tools/tool-meta.ts +32 -0
- package/platform/cloud-tools/web-search.ts +15 -0
- package/platform/runtime-ai/gateway.ts +76 -0
- package/platform/runtime-ai/index.ts +20 -0
- package/platform/runtime-ai/register.ts +26 -0
- package/platform/runtime-ai/session-fetch.ts +124 -0
- package/platform/runtime-auth/index.ts +331 -0
- package/src/async-tasks.ts +273 -0
- package/src/attachments.ts +109 -0
- package/src/backgroundable.ts +88 -0
- package/src/bounded-output.ts +159 -0
- package/src/dir-conventions.ts +238 -0
- package/src/extract/cache.ts +40 -0
- package/src/extract/docx.ts +18 -0
- package/src/extract/pdf.ts +54 -0
- package/src/extract/sheet.ts +56 -0
- package/src/file-kind.ts +258 -0
- package/src/file-view.ts +80 -0
- package/src/gateway-fetch.ts +115 -0
- package/src/glob-match.ts +13 -0
- package/src/hooks.ts +213 -0
- package/src/index.ts +419 -0
- package/src/initiator-auth.ts +81 -0
- package/src/instructions.ts +224 -0
- package/src/list-files.ts +41 -0
- package/src/mock-model.ts +572 -0
- package/src/park-delivery.ts +247 -0
- package/src/path-locks.ts +52 -0
- package/src/read-file-content.ts +142 -0
- package/src/read-text.ts +40 -0
- package/src/redeliver.ts +155 -0
- package/src/run.ts +159 -0
- package/src/sandbox-io.ts +414 -0
- package/src/state-files.ts +460 -0
- package/src/state-sandbox.ts +710 -0
- package/src/state.ts +96 -0
- package/src/steer-inbox.ts +105 -0
- package/src/steer-tool.ts +83 -0
- package/src/steer.ts +146 -0
- package/src/task.ts +320 -0
- package/src/tools/bash.ts +143 -0
- package/src/tools/edit.ts +58 -0
- package/src/tools/glob.ts +56 -0
- package/src/tools/grep.ts +188 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/tasks.ts +241 -0
- package/src/tools/webfetch.ts +368 -0
- package/src/tools/write.ts +42 -0
- package/src/walk.ts +112 -0
- package/src/watch-output.ts +104 -0
- package/src/web-fetch.ts +324 -0
- package/src/web-page.ts +179 -0
- package/src/workspace-io.ts +225 -0
- 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
|
+
}
|