@warlock.js/ai-workspace 5.28.0 → 5.30.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/CHANGELOG.md +30 -0
- package/README.md +6 -0
- package/cjs/index.cjs +201 -73
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +38 -8
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +1 -1
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +11 -3
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +1 -1
- package/esm/contracts/tool-io.type.d.mts +47 -8
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +17 -1
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +10 -14
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +9 -2
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +13 -14
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +1 -1
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +1 -1
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/ops.d.mts +1 -1
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +121 -28
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/policy.d.mts +1 -1
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +1 -1
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/tokenize-command.mjs +1 -1
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/tools/edit-file.d.mts +1 -1
- package/esm/tools/edit-file.d.mts.map +1 -1
- package/esm/tools/edit-file.mjs +1 -1
- package/esm/tools/edit-file.mjs.map +1 -1
- package/esm/tools/glob.d.mts +1 -1
- package/esm/tools/glob.d.mts.map +1 -1
- package/esm/tools/glob.mjs +1 -1
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.d.mts +1 -1
- package/esm/tools/grep.d.mts.map +1 -1
- package/esm/tools/grep.mjs +1 -1
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/read-file.d.mts +5 -5
- package/esm/tools/read-file.d.mts.map +1 -1
- package/esm/tools/read-file.mjs +8 -19
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +5 -1
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +9 -2
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +3 -2
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +5 -4
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +1 -1
- package/esm/tools/schema.mjs.map +1 -1
- package/esm/tools/write-file.d.mts +1 -1
- package/esm/tools/write-file.d.mts.map +1 -1
- package/esm/tools/write-file.mjs +1 -1
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +1 -1
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +1 -1
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +48 -12
- package/llms.txt +2 -2
- package/package.json +5 -4
- package/skills/README.md +3 -3
- package/skills/build-loop-agent/SKILL.md +4 -4
- package/skills/use-a-workspace/SKILL.md +44 -8
package/esm/backends/local.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { WorkspaceBackend } from "../contracts/workspace-backend.contract.mjs";
|
|
2
2
|
|
|
3
|
-
//#region
|
|
3
|
+
//#region ../../packages/ai-workspace/src/backends/local.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Create the **local** workspace backend — the default executor that runs the
|
|
6
6
|
* workspace over the real disk (`@warlock.js/fs`) and the local shell
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"local.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/local.ts"],"mappings":";;;;;AAsZA;;;;AAAsD;;;;;;;;;iBAAtC,kBAAA,IAAsB,gBAAgB"}
|
package/esm/backends/local.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { fs } from "@warlock.js/fs";
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { platform } from "node:process";
|
|
6
6
|
|
|
7
|
-
//#region
|
|
7
|
+
//#region ../../packages/ai-workspace/src/backends/local.ts
|
|
8
8
|
/**
|
|
9
9
|
* Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw
|
|
10
10
|
* executor truncates each stream once this many bytes have accumulated so a
|
|
@@ -51,7 +51,9 @@ function killTree(pid, child) {
|
|
|
51
51
|
String(pid),
|
|
52
52
|
"/T",
|
|
53
53
|
"/F"
|
|
54
|
-
], { windowsHide: true })
|
|
54
|
+
], { windowsHide: true }).on("error", () => {
|
|
55
|
+
child.kill("SIGKILL");
|
|
56
|
+
});
|
|
55
57
|
return;
|
|
56
58
|
}
|
|
57
59
|
try {
|
|
@@ -168,11 +170,13 @@ var LocalBackend = class {
|
|
|
168
170
|
* cannot be spawned shell-less) with every element individually quoted.
|
|
169
171
|
* `cwd`, `env`, and the timeout are taken verbatim from the ops layer
|
|
170
172
|
* (the environment is NOT merged with `process.env`). On timeout the
|
|
171
|
-
* process is SIGKILLed and `timedOut` is set. `
|
|
172
|
-
*
|
|
173
|
+
* process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
|
|
174
|
+
* mid-run the process tree is SIGKILLed the same way and `cancelled` is
|
|
175
|
+
* set; an already-aborted `signal` resolves `cancelled` without spawning.
|
|
176
|
+
* `stdout`/`stderr` are captured and byte-capped per {@link MAX_STREAM_BYTES}.
|
|
173
177
|
*
|
|
174
178
|
* Never rejects for a non-zero exit, a missing executable, a refused
|
|
175
|
-
* command line, or a
|
|
179
|
+
* command line, a timeout or a cancellation — those are reported through the resolved
|
|
176
180
|
* {@link WorkspaceBackendExecResult} so the ops layer can surface them
|
|
177
181
|
* as tool-error data.
|
|
178
182
|
*/
|
|
@@ -184,12 +188,24 @@ var LocalBackend = class {
|
|
|
184
188
|
stderr,
|
|
185
189
|
timedOut: false
|
|
186
190
|
});
|
|
191
|
+
const signal = opts.signal;
|
|
192
|
+
if (signal?.aborted) {
|
|
193
|
+
resolve({
|
|
194
|
+
exitCode: 130,
|
|
195
|
+
stdout: "",
|
|
196
|
+
stderr: "Command was not executed: the invocation was cancelled before it started.",
|
|
197
|
+
timedOut: false,
|
|
198
|
+
cancelled: true
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
187
202
|
const argv = tokenizeCommand(command);
|
|
188
|
-
|
|
203
|
+
const executable = argv?.[0];
|
|
204
|
+
if (argv === null || executable === void 0) {
|
|
189
205
|
refuse("Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time.");
|
|
190
206
|
return;
|
|
191
207
|
}
|
|
192
|
-
let file =
|
|
208
|
+
let file = executable;
|
|
193
209
|
let args = argv.slice(1);
|
|
194
210
|
let windowsVerbatimArguments = false;
|
|
195
211
|
if (platform === "win32") {
|
|
@@ -215,20 +231,31 @@ var LocalBackend = class {
|
|
|
215
231
|
let stdoutBytes = 0;
|
|
216
232
|
let stderrBytes = 0;
|
|
217
233
|
let timedOut = false;
|
|
234
|
+
let cancelled = false;
|
|
218
235
|
let settled = false;
|
|
236
|
+
let exited = false;
|
|
219
237
|
const timer = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? setTimeout(() => {
|
|
238
|
+
if (cancelled) return;
|
|
220
239
|
timedOut = true;
|
|
221
240
|
killTree(child.pid, child);
|
|
222
241
|
}, opts.timeoutMs) : void 0;
|
|
242
|
+
const onAbort = () => {
|
|
243
|
+
if (settled || timedOut) return;
|
|
244
|
+
if (!exited) cancelled = true;
|
|
245
|
+
killTree(child.pid, child);
|
|
246
|
+
};
|
|
247
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
223
248
|
const settle = (exitCode) => {
|
|
224
249
|
if (settled) return;
|
|
225
250
|
settled = true;
|
|
226
251
|
if (timer !== void 0) clearTimeout(timer);
|
|
252
|
+
signal?.removeEventListener("abort", onAbort);
|
|
227
253
|
resolve({
|
|
228
254
|
exitCode,
|
|
229
255
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
230
256
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
231
|
-
timedOut
|
|
257
|
+
timedOut,
|
|
258
|
+
cancelled
|
|
232
259
|
});
|
|
233
260
|
};
|
|
234
261
|
child.stdout?.on("data", (chunk) => {
|
|
@@ -240,6 +267,9 @@ var LocalBackend = class {
|
|
|
240
267
|
child.on("error", () => {
|
|
241
268
|
settle(127);
|
|
242
269
|
});
|
|
270
|
+
child.on("exit", () => {
|
|
271
|
+
exited = true;
|
|
272
|
+
});
|
|
243
273
|
child.on("close", (code, signal) => {
|
|
244
274
|
if (code === null) {
|
|
245
275
|
settle(128 + (signal === "SIGKILL" ? 9 : 1));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/backends/local.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { realpath } from \"node:fs/promises\";\nimport { platform } from \"node:process\";\nimport { fs } from \"@warlock.js/fs\";\nimport { tokenizeCommand } from \"../policy/tokenize-command\";\nimport type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n} from \"../contracts/workspace-backend.contract\";\n\n/**\n * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw\n * executor truncates each stream once this many bytes have accumulated so a\n * runaway command cannot exhaust memory; the ops layer applies its own\n * policy-driven cap (and the `truncated` flag) on top. Generous enough that\n * ordinary command output is never clipped here.\n */\nconst MAX_STREAM_BYTES = 10 * 1024 * 1024;\n\n/**\n * Append a chunk to a capped list of buffers, tracking the running byte\n * total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes\n * past the cap are dropped rather than buffered.\n */\nfunction pushCapped(chunks: Buffer[], total: number, chunk: Buffer): number {\n if (total >= MAX_STREAM_BYTES) {\n return total;\n }\n\n const remaining = MAX_STREAM_BYTES - total;\n\n if (chunk.length <= remaining) {\n chunks.push(chunk);\n\n return total + chunk.length;\n }\n\n chunks.push(chunk.subarray(0, remaining));\n\n return MAX_STREAM_BYTES;\n}\n\n/**\n * Force-kill a spawned command and its entire process tree.\n *\n * The direct child may have grandchildren (on Windows it is the `cmd.exe`\n * wrapper around a batch shim; anywhere, an allowed `npm`/`node` can spawn\n * further processes), so signalling the direct child alone could leave a\n * long-running grandchild alive and the `exec` promise unsettled. We\n * therefore kill the whole group:\n * - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.\n * - **POSIX** — the child is spawned `detached`, becoming its own process\n * group leader, so `process.kill(-pid)` SIGKILLs the group.\n */\nfunction killTree(pid: number | undefined, child: { kill(signal: NodeJS.Signals): boolean }): void {\n if (pid === undefined) {\n child.kill(\"SIGKILL\");\n\n return;\n }\n\n if (platform === \"win32\") {\n spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\n\n return;\n }\n\n try {\n process.kill(-pid, \"SIGKILL\");\n } catch {\n // The group may already be gone; fall back to the direct child.\n child.kill(\"SIGKILL\");\n }\n}\n\n/**\n * Argument bytes `cmd.exe` cannot carry safely even inside double quotes:\n * an embedded quote breaks out of the quoted span, `%` triggers variable\n * expansion regardless of quoting, and newlines end the command line. An\n * argv containing any of these is refused rather than risked (the\n * BatBadBut / CVE-2024-24576 class of `cmd.exe` argument smuggling).\n */\nconst WIN32_UNSAFE_ARGUMENT = /[\"%\\r\\n]/;\n\n/**\n * Build the `cmd.exe` invocation that runs an argv on Windows. Batch shims\n * (`npm.cmd`, `npx.cmd`, …) cannot be spawned directly without a shell\n * (Node rejects them since CVE-2024-27980), so the argv is run through\n * `cmd.exe /d /s /c` with every element individually double-quoted —\n * quoted spans are literal to cmd's parser, so pipes/ampersands inside an\n * argument stay argument data. Returns `null` when an element contains a\n * byte cmd cannot quote safely (see {@link WIN32_UNSAFE_ARGUMENT}).\n *\n * The caller must spawn with `windowsVerbatimArguments: true` so Node does\n * not re-quote the already-quoted command line.\n */\nfunction toWin32CmdInvocation(\n argv: string[],\n): { file: string; args: string[] } | null {\n if (argv.some((element) => WIN32_UNSAFE_ARGUMENT.test(element))) {\n return null;\n }\n\n const commandLine = argv.map((element) => `\"${element}\"`).join(\" \");\n\n return {\n file: process.env.ComSpec ?? \"cmd.exe\",\n args: [\"/d\", \"/s\", \"/c\", `\"${commandLine}\"`],\n };\n}\n\n/**\n * The real-disk executor: every filesystem method delegates to\n * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a\n * process via `node:child_process`. It is deliberately **policy-agnostic** —\n * it receives already-resolved absolute paths and an already-resolved\n * environment + timeout from the ops layer, and just performs the side\n * effect. See {@link WorkspaceBackend} for the contract this implements.\n *\n * Constructed via {@link createLocalBackend}; the class itself is internal.\n */\nclass LocalBackend implements WorkspaceBackend {\n /** Read a file's full UTF-8 content at an absolute path. */\n public async readFile(absPath: string): Promise<string> {\n return fs.files.get(absPath);\n }\n\n /**\n * Write full content to an absolute path. Uses `atomicWriteAsync`, so a\n * concurrent reader never observes a half-written file, and missing parent\n * directories are created.\n */\n public async writeFile(absPath: string, content: string): Promise<void> {\n await fs.files.put(absPath, content, { atomic: true });\n }\n\n /** Whether anything (file or directory) exists at an absolute path. */\n public async exists(absPath: string): Promise<boolean> {\n return fs.exists(absPath);\n }\n\n /** Create a directory (and any missing parents) at an absolute path; idempotent. */\n public async mkdir(absPath: string): Promise<void> {\n await fs.dirs.ensure(absPath);\n }\n\n /**\n * Remove a file or directory tree at an absolute path. Stats the target to\n * pick the right op — `fs.dirs.remove` (recursive) for a directory,\n * `fs.files.remove` for anything else. A path that does not exist is a no-op\n * (the stat's ENOENT short-circuits, and both removes swallow `ENOENT`).\n */\n public async remove(absPath: string): Promise<void> {\n let isDirectory = false;\n\n try {\n isDirectory = (await fs.files.stats(absPath)).type === \"directory\";\n } catch (error) {\n if ((error as NodeJS.ErrnoException)?.code === \"ENOENT\") {\n return;\n }\n\n throw error;\n }\n\n if (isDirectory) {\n await fs.dirs.remove(absPath);\n\n return;\n }\n\n await fs.files.remove(absPath);\n }\n\n /** List immediate children of an absolute directory as absolute paths. */\n public async list(absDir: string): Promise<string[]> {\n return fs.dirs.list(absDir);\n }\n\n /**\n * Resolve symlinks and `..` segments to a canonical absolute path — the\n * primitive the ops-layer jail uses to detect escapes. Delegates to\n * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`\n * does not expose.\n */\n public async realpath(absPath: string): Promise<string> {\n return realpath(absPath);\n }\n\n /**\n * Run a command and capture its outcome. The command line is tokenized\n * into an argv (quotes respected, NO shell semantics — see\n * `tokenizeCommand`) and spawned **without a shell**, so metacharacters\n * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra\n * commands past the ops layer's allowlist; a command they appear\n * unquoted in is refused with exit code 127. On Windows the argv runs\n * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`\n * cannot be spawned shell-less) with every element individually quoted.\n * `cwd`, `env`, and the timeout are taken verbatim from the ops layer\n * (the environment is NOT merged with `process.env`). On timeout the\n * process is SIGKILLed and `timedOut` is set. `stdout`/`stderr` are\n * captured and byte-capped per {@link MAX_STREAM_BYTES}.\n *\n * Never rejects for a non-zero exit, a missing executable, a refused\n * command line, or a timeout — those are reported through the resolved\n * {@link WorkspaceBackendExecResult} so the ops layer can surface them\n * as tool-error data.\n */\n public exec(\n command: string,\n opts: WorkspaceBackendExecOptions = {},\n ): Promise<WorkspaceBackendExecResult> {\n return new Promise<WorkspaceBackendExecResult>((resolve) => {\n const refuse = (stderr: string): void =>\n resolve({ exitCode: 127, stdout: \"\", stderr, timedOut: false });\n\n const argv = tokenizeCommand(command);\n\n if (argv === null) {\n refuse(\n \"Command was not executed: it is empty, has unbalanced quotes, or \" +\n \"contains unquoted shell metacharacters (;, &, |, `, $, <, >, \" +\n \"parentheses). Commands run without a shell — pass metacharacters \" +\n \"inside quotes as literal arguments, or run one command at a time.\",\n );\n\n return;\n }\n\n let file = argv[0];\n let args = argv.slice(1);\n let windowsVerbatimArguments = false;\n\n if (platform === \"win32\") {\n const invocation = toWin32CmdInvocation(argv);\n\n if (invocation === null) {\n refuse(\n 'Command was not executed: on Windows, arguments containing \", %, ' +\n \"or newlines cannot be passed to cmd.exe safely.\",\n );\n\n return;\n }\n\n file = invocation.file;\n args = invocation.args;\n windowsVerbatimArguments = true;\n }\n\n const child = spawn(file, args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments,\n // POSIX: own process group so a timeout SIGKILL reaps the whole\n // process tree, not just the direct child. Harmless on Windows\n // (ignored; there we tree-kill via taskkill instead).\n detached: platform !== \"win32\",\n });\n\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n let stdoutBytes = 0;\n let stderrBytes = 0;\n let timedOut = false;\n let settled = false;\n\n const timer =\n opts.timeoutMs !== undefined && opts.timeoutMs > 0\n ? setTimeout(() => {\n timedOut = true;\n killTree(child.pid, child);\n }, opts.timeoutMs)\n : undefined;\n\n const settle = (exitCode: number) => {\n if (settled) {\n return;\n }\n\n settled = true;\n\n if (timer !== undefined) {\n clearTimeout(timer);\n }\n\n resolve({\n exitCode,\n stdout: Buffer.concat(stdoutChunks).toString(\"utf8\"),\n stderr: Buffer.concat(stderrChunks).toString(\"utf8\"),\n timedOut,\n });\n };\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);\n });\n\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);\n });\n\n // A spawn failure (e.g. the executable cannot be found) surfaces as\n // an error event with no exit; report it as a conventional\n // \"command not found\" exit code rather than rejecting.\n child.on(\"error\", () => {\n settle(127);\n });\n\n child.on(\"close\", (code, signal) => {\n // A null code means the process was terminated by a signal (our\n // timeout SIGKILL, or an external kill). Map that to the POSIX\n // 128 + signal-number convention so callers see a non-zero exit.\n if (code === null) {\n const signalNumber = signal === \"SIGKILL\" ? 9 : 1;\n settle(128 + signalNumber);\n\n return;\n }\n\n settle(code);\n });\n });\n }\n}\n\n/**\n * Create the **local** workspace backend — the default executor that runs the\n * workspace over the real disk (`@warlock.js/fs`) and the local shell\n * (`node:child_process`).\n *\n * The returned object is policy-agnostic: it expects already-jail-resolved\n * absolute paths and an already-resolved environment/timeout from the ops\n * layer. Pair it with {@link WorkspaceOps} for the actual cwd jail,\n * allow/deny lists, hashing, and output policy.\n *\n * @example\n * const backend = createLocalBackend();\n * await backend.writeFile(\"/srv/app/src/index.ts\", \"export const x = 1;\");\n * const { exitCode } = await backend.exec(\"node -v\", { cwd: \"/srv/app\" });\n */\nexport function createLocalBackend(): WorkspaceBackend {\n return new LocalBackend();\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"local.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/local.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { realpath } from \"node:fs/promises\";\nimport { platform } from \"node:process\";\nimport { fs } from \"@warlock.js/fs\";\nimport { tokenizeCommand } from \"../policy/tokenize-command\";\nimport type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n} from \"../contracts/workspace-backend.contract\";\n\n/**\n * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw\n * executor truncates each stream once this many bytes have accumulated so a\n * runaway command cannot exhaust memory; the ops layer applies its own\n * policy-driven cap (and the `truncated` flag) on top. Generous enough that\n * ordinary command output is never clipped here.\n */\nconst MAX_STREAM_BYTES = 10 * 1024 * 1024;\n\n/**\n * Append a chunk to a capped list of buffers, tracking the running byte\n * total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes\n * past the cap are dropped rather than buffered.\n */\nfunction pushCapped(chunks: Buffer[], total: number, chunk: Buffer): number {\n if (total >= MAX_STREAM_BYTES) {\n return total;\n }\n\n const remaining = MAX_STREAM_BYTES - total;\n\n if (chunk.length <= remaining) {\n chunks.push(chunk);\n\n return total + chunk.length;\n }\n\n chunks.push(chunk.subarray(0, remaining));\n\n return MAX_STREAM_BYTES;\n}\n\n/**\n * Force-kill a spawned command and its entire process tree.\n *\n * The direct child may have grandchildren (on Windows it is the `cmd.exe`\n * wrapper around a batch shim; anywhere, an allowed `npm`/`node` can spawn\n * further processes), so signalling the direct child alone could leave a\n * long-running grandchild alive and the `exec` promise unsettled. We\n * therefore kill the whole group:\n * - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.\n * - **POSIX** — the child is spawned `detached`, becoming its own process\n * group leader, so `process.kill(-pid)` SIGKILLs the group.\n */\nfunction killTree(pid: number | undefined, child: { kill(signal: NodeJS.Signals): boolean }): void {\n if (pid === undefined) {\n child.kill(\"SIGKILL\");\n\n return;\n }\n\n if (platform === \"win32\") {\n const killer = spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\n\n // Without a listener a failed spawn (taskkill missing from PATH) is an\n // unhandled 'error' event that crashes the process; fall back to the\n // direct child instead.\n killer.on(\"error\", () => {\n child.kill(\"SIGKILL\");\n });\n\n return;\n }\n\n try {\n process.kill(-pid, \"SIGKILL\");\n } catch {\n // The group may already be gone; fall back to the direct child.\n child.kill(\"SIGKILL\");\n }\n}\n\n/**\n * Argument bytes `cmd.exe` cannot carry safely even inside double quotes:\n * an embedded quote breaks out of the quoted span, `%` triggers variable\n * expansion regardless of quoting, and newlines end the command line. An\n * argv containing any of these is refused rather than risked (the\n * BatBadBut / CVE-2024-24576 class of `cmd.exe` argument smuggling).\n */\nconst WIN32_UNSAFE_ARGUMENT = /[\"%\\r\\n]/;\n\n/**\n * Build the `cmd.exe` invocation that runs an argv on Windows. Batch shims\n * (`npm.cmd`, `npx.cmd`, …) cannot be spawned directly without a shell\n * (Node rejects them since CVE-2024-27980), so the argv is run through\n * `cmd.exe /d /s /c` with every element individually double-quoted —\n * quoted spans are literal to cmd's parser, so pipes/ampersands inside an\n * argument stay argument data. Returns `null` when an element contains a\n * byte cmd cannot quote safely (see {@link WIN32_UNSAFE_ARGUMENT}).\n *\n * The caller must spawn with `windowsVerbatimArguments: true` so Node does\n * not re-quote the already-quoted command line.\n */\nfunction toWin32CmdInvocation(\n argv: string[],\n): { file: string; args: string[] } | null {\n if (argv.some((element) => WIN32_UNSAFE_ARGUMENT.test(element))) {\n return null;\n }\n\n const commandLine = argv.map((element) => `\"${element}\"`).join(\" \");\n\n return {\n file: process.env.ComSpec ?? \"cmd.exe\",\n args: [\"/d\", \"/s\", \"/c\", `\"${commandLine}\"`],\n };\n}\n\n/**\n * The real-disk executor: every filesystem method delegates to\n * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a\n * process via `node:child_process`. It is deliberately **policy-agnostic** —\n * it receives already-resolved absolute paths and an already-resolved\n * environment + timeout from the ops layer, and just performs the side\n * effect. See {@link WorkspaceBackend} for the contract this implements.\n *\n * Constructed via {@link createLocalBackend}; the class itself is internal.\n */\nclass LocalBackend implements WorkspaceBackend {\n /** Read a file's full UTF-8 content at an absolute path. */\n public async readFile(absPath: string): Promise<string> {\n return fs.files.get(absPath);\n }\n\n /**\n * Write full content to an absolute path. Uses `atomicWriteAsync`, so a\n * concurrent reader never observes a half-written file, and missing parent\n * directories are created.\n */\n public async writeFile(absPath: string, content: string): Promise<void> {\n await fs.files.put(absPath, content, { atomic: true });\n }\n\n /** Whether anything (file or directory) exists at an absolute path. */\n public async exists(absPath: string): Promise<boolean> {\n return fs.exists(absPath);\n }\n\n /** Create a directory (and any missing parents) at an absolute path; idempotent. */\n public async mkdir(absPath: string): Promise<void> {\n await fs.dirs.ensure(absPath);\n }\n\n /**\n * Remove a file or directory tree at an absolute path. Stats the target to\n * pick the right op — `fs.dirs.remove` (recursive) for a directory,\n * `fs.files.remove` for anything else. A path that does not exist is a no-op\n * (the stat's ENOENT short-circuits, and both removes swallow `ENOENT`).\n */\n public async remove(absPath: string): Promise<void> {\n let isDirectory = false;\n\n try {\n isDirectory = (await fs.files.stats(absPath)).type === \"directory\";\n } catch (error) {\n if ((error as NodeJS.ErrnoException)?.code === \"ENOENT\") {\n return;\n }\n\n throw error;\n }\n\n if (isDirectory) {\n await fs.dirs.remove(absPath);\n\n return;\n }\n\n await fs.files.remove(absPath);\n }\n\n /** List immediate children of an absolute directory as absolute paths. */\n public async list(absDir: string): Promise<string[]> {\n return fs.dirs.list(absDir);\n }\n\n /**\n * Resolve symlinks and `..` segments to a canonical absolute path — the\n * primitive the ops-layer jail uses to detect escapes. Delegates to\n * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`\n * does not expose.\n */\n public async realpath(absPath: string): Promise<string> {\n return realpath(absPath);\n }\n\n /**\n * Run a command and capture its outcome. The command line is tokenized\n * into an argv (quotes respected, NO shell semantics — see\n * `tokenizeCommand`) and spawned **without a shell**, so metacharacters\n * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra\n * commands past the ops layer's allowlist; a command they appear\n * unquoted in is refused with exit code 127. On Windows the argv runs\n * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`\n * cannot be spawned shell-less) with every element individually quoted.\n * `cwd`, `env`, and the timeout are taken verbatim from the ops layer\n * (the environment is NOT merged with `process.env`). On timeout the\n * process tree is SIGKILLed and `timedOut` is set. When `signal` aborts\n * mid-run the process tree is SIGKILLed the same way and `cancelled` is\n * set; an already-aborted `signal` resolves `cancelled` without spawning.\n * `stdout`/`stderr` are captured and byte-capped per {@link MAX_STREAM_BYTES}.\n *\n * Never rejects for a non-zero exit, a missing executable, a refused\n * command line, a timeout or a cancellation — those are reported through the resolved\n * {@link WorkspaceBackendExecResult} so the ops layer can surface them\n * as tool-error data.\n */\n public exec(\n command: string,\n opts: WorkspaceBackendExecOptions = {},\n ): Promise<WorkspaceBackendExecResult> {\n return new Promise<WorkspaceBackendExecResult>((resolve) => {\n const refuse = (stderr: string): void =>\n resolve({ exitCode: 127, stdout: \"\", stderr, timedOut: false });\n\n const signal = opts.signal;\n\n // Never spawn for an invocation that was cancelled before it started.\n if (signal?.aborted) {\n resolve({\n exitCode: 130,\n stdout: \"\",\n stderr: \"Command was not executed: the invocation was cancelled before it started.\",\n timedOut: false,\n cancelled: true,\n });\n\n return;\n }\n\n const argv = tokenizeCommand(command);\n const executable = argv?.[0];\n\n if (argv === null || executable === undefined) {\n refuse(\n \"Command was not executed: it is empty, has unbalanced quotes, or \" +\n \"contains unquoted shell metacharacters (;, &, |, `, $, <, >, \" +\n \"parentheses). Commands run without a shell — pass metacharacters \" +\n \"inside quotes as literal arguments, or run one command at a time.\",\n );\n\n return;\n }\n\n let file = executable;\n let args = argv.slice(1);\n let windowsVerbatimArguments = false;\n\n if (platform === \"win32\") {\n const invocation = toWin32CmdInvocation(argv);\n\n if (invocation === null) {\n refuse(\n 'Command was not executed: on Windows, arguments containing \", %, ' +\n \"or newlines cannot be passed to cmd.exe safely.\",\n );\n\n return;\n }\n\n file = invocation.file;\n args = invocation.args;\n windowsVerbatimArguments = true;\n }\n\n const child = spawn(file, args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments,\n // POSIX: own process group so a timeout SIGKILL reaps the whole\n // process tree, not just the direct child. Harmless on Windows\n // (ignored; there we tree-kill via taskkill instead).\n detached: platform !== \"win32\",\n });\n\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n let stdoutBytes = 0;\n let stderrBytes = 0;\n let timedOut = false;\n let cancelled = false;\n let settled = false;\n // The direct child has exited; 'close' may still be pending while its\n // stdio drains (or a grandchild holds the pipes open).\n let exited = false;\n\n // Timeout and cancellation are mutually exclusive: whichever kills the\n // tree first owns the outcome, so a result is never both.\n const timer =\n opts.timeoutMs !== undefined && opts.timeoutMs > 0\n ? setTimeout(() => {\n if (cancelled) {\n return;\n }\n\n timedOut = true;\n killTree(child.pid, child);\n }, opts.timeoutMs)\n : undefined;\n\n const onAbort = (): void => {\n if (settled || timedOut) {\n return;\n }\n\n // The command already finished: report its real outcome, not a\n // cancellation. Still reap the tree so a grandchild holding the\n // pipes open cannot keep `close` (and this promise) pending.\n if (!exited) {\n cancelled = true;\n }\n\n killTree(child.pid, child);\n };\n\n // Attached synchronously after `spawn`, so there is no window in which\n // an abort could be missed; removed in `settle` so a long-lived signal\n // shared across many commands does not accumulate listeners.\n signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n const settle = (exitCode: number) => {\n if (settled) {\n return;\n }\n\n settled = true;\n\n if (timer !== undefined) {\n clearTimeout(timer);\n }\n\n signal?.removeEventListener(\"abort\", onAbort);\n\n resolve({\n exitCode,\n stdout: Buffer.concat(stdoutChunks).toString(\"utf8\"),\n stderr: Buffer.concat(stderrChunks).toString(\"utf8\"),\n timedOut,\n cancelled,\n });\n };\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);\n });\n\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);\n });\n\n // A spawn failure (e.g. the executable cannot be found) surfaces as\n // an error event with no exit; report it as a conventional\n // \"command not found\" exit code rather than rejecting.\n child.on(\"error\", () => {\n settle(127);\n });\n\n child.on(\"exit\", () => {\n exited = true;\n });\n\n child.on(\"close\", (code, signal) => {\n // A null code means the process was terminated by a signal (our\n // timeout SIGKILL, or an external kill). Map that to the POSIX\n // 128 + signal-number convention so callers see a non-zero exit.\n if (code === null) {\n const signalNumber = signal === \"SIGKILL\" ? 9 : 1;\n settle(128 + signalNumber);\n\n return;\n }\n\n settle(code);\n });\n });\n }\n}\n\n/**\n * Create the **local** workspace backend — the default executor that runs the\n * workspace over the real disk (`@warlock.js/fs`) and the local shell\n * (`node:child_process`).\n *\n * The returned object is policy-agnostic: it expects already-jail-resolved\n * absolute paths and an already-resolved environment/timeout from the ops\n * layer. Pair it with {@link WorkspaceOps} for the actual cwd jail,\n * allow/deny lists, hashing, and output policy.\n *\n * @example\n * const backend = createLocalBackend();\n * await backend.writeFile(\"/srv/app/src/index.ts\", \"export const x = 1;\");\n * const { exitCode } = await backend.exec(\"node -v\", { cwd: \"/srv/app\" });\n */\nexport function createLocalBackend(): WorkspaceBackend {\n return new LocalBackend();\n}\n"],"mappings":"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"}
|
package/esm/backends/mock.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { WorkspaceBackend, WorkspaceBackendExecResult } from "../contracts/workspace-backend.contract.mjs";
|
|
2
2
|
|
|
3
|
-
//#region
|
|
3
|
+
//#region ../../packages/ai-workspace/src/backends/mock.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* A scripted outcome for the mock backend's `exec`. Mirrors
|
|
6
6
|
* {@link WorkspaceBackendExecResult} but every field is optional so a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"mock.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/mock.ts"],"mappings":";;;;;AAYA;;;;KAAY,cAAA,GAAiB,OAAO,CAAC,0BAAA;AAUrC;;;;;;;;AAAA,UAAiB,eAAA;EAEP;EAAR,KAAA,GAAQ,MAAA;EAEG;EAAX,QAAA,GAAW,MAAA,SAAe,cAAA;AAAA;AAAc;AA2O1C;;;;;;;;;;;;;AAEmB;;;;;;;;;;;;;;;;;;;;AA7OuB,iBA2O1B,iBAAA,CACd,IAAA,GAAO,MAAA,mBAAyB,eAAA,GAC/B,gBAAA"}
|
package/esm/backends/mock.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region
|
|
1
|
+
//#region ../../packages/ai-workspace/src/backends/mock.ts
|
|
2
2
|
/**
|
|
3
3
|
* Normalize an absolute path to a stable in-memory key: forward slashes,
|
|
4
4
|
* collapsed duplicate separators, and resolved `.` / `..` segments. There
|
|
@@ -98,13 +98,21 @@ var MockBackend = class {
|
|
|
98
98
|
async realpath(absPath) {
|
|
99
99
|
return canonicalize(absPath);
|
|
100
100
|
}
|
|
101
|
-
async exec(command,
|
|
101
|
+
async exec(command, opts) {
|
|
102
|
+
if (opts?.signal?.aborted) return {
|
|
103
|
+
exitCode: 130,
|
|
104
|
+
stdout: "",
|
|
105
|
+
stderr: "",
|
|
106
|
+
timedOut: false,
|
|
107
|
+
cancelled: true
|
|
108
|
+
};
|
|
102
109
|
const scripted = this.commands.get(command);
|
|
103
110
|
return {
|
|
104
111
|
exitCode: scripted?.exitCode ?? 0,
|
|
105
112
|
stdout: scripted?.stdout ?? "",
|
|
106
113
|
stderr: scripted?.stderr ?? "",
|
|
107
|
-
timedOut: scripted?.timedOut ?? false
|
|
114
|
+
timedOut: scripted?.timedOut ?? false,
|
|
115
|
+
cancelled: scripted?.cancelled ?? false
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock.mjs","names":[],"sources":["../../../../../../../ai-workspace/src/backends/mock.ts"],"sourcesContent":["import type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n} from \"../contracts/workspace-backend.contract\";\n\n/**\n * A scripted outcome for the mock backend's `exec`. Mirrors\n * {@link WorkspaceBackendExecResult} but every field is optional so a\n * registration can specify only what it cares about — the rest fall back\n * to a successful, empty-output, non-timed-out run.\n */\nexport type MockExecResult = Partial<WorkspaceBackendExecResult>;\n\n/**\n * The two seedable inputs of a mock backend:\n *\n * - `files` — initial file tree as `absolutePath -> content`. Parent\n * directories of each seeded file are implicitly created.\n * - `commands` — scripted `exec` outcomes keyed on the **exact** command\n * line. Any command not registered here resolves to a `0`-exit no-op.\n */\nexport interface MockBackendSeed {\n /** Initial file contents, keyed by absolute path. */\n files?: Record<string, string>;\n /** Scripted `exec` results, keyed by exact command line. */\n commands?: Record<string, MockExecResult>;\n}\n\n/**\n * Normalize an absolute path to a stable in-memory key: forward slashes,\n * collapsed duplicate separators, and resolved `.` / `..` segments. There\n * are no symlinks in memory, so this is a pure lexical canonicalization —\n * exactly what the backend's `realpath` promises.\n */\nfunction canonicalize(absPath: string): string {\n const unified = absPath.replace(/\\\\/g, \"/\");\n // Preserve a leading slash (POSIX root) or a `C:`-style Windows drive\n // prefix; everything else is a normal segment.\n const driveMatch = unified.match(/^([a-zA-Z]:)?\\/?/);\n const prefix = driveMatch ? driveMatch[0] : \"\";\n const rest = unified.slice(prefix.length);\n\n const resolved: string[] = [];\n\n for (const segment of rest.split(\"/\")) {\n if (segment === \"\" || segment === \".\") {\n continue;\n }\n\n if (segment === \"..\") {\n resolved.pop();\n\n continue;\n }\n\n resolved.push(segment);\n }\n\n const joined = resolved.join(\"/\");\n\n // A trailing slash on the prefix means it was absolute; keep it so the\n // result stays absolute even when the body is empty (the root itself).\n const normalizedPrefix = prefix.endsWith(\"/\") ? prefix : `${prefix}/`;\n\n return joined.length > 0 ? `${normalizedPrefix}${joined}` : normalizedPrefix;\n}\n\n/** All ancestor directory keys of a canonical path, root-first. */\nfunction ancestorsOf(canonicalPath: string): string[] {\n const lastSlash = canonicalPath.lastIndexOf(\"/\");\n\n if (lastSlash <= 0) {\n return [];\n }\n\n const parent = canonicalPath.slice(0, lastSlash);\n const result = ancestorsOf(parent);\n\n result.push(parent);\n\n return result;\n}\n\n/**\n * The in-memory executor behind {@link createMockBackend}. Holds the file\n * tree in a `Map`, the directory set in a `Set`, and the scripted command\n * table in a second `Map` — no disk, no child processes, fully\n * deterministic. Construct it via the factory, never directly.\n */\nclass MockBackend implements WorkspaceBackend {\n /** Canonical file path -> content. */\n private readonly files = new Map<string, string>();\n\n /** Canonical directory paths that exist (including implicit parents). */\n private readonly directories = new Set<string>();\n\n /** Exact command line -> scripted outcome. */\n private readonly commands = new Map<string, MockExecResult>();\n\n public constructor(seed?: MockBackendSeed) {\n for (const [path, content] of Object.entries(seed?.files ?? {})) {\n const canonical = canonicalize(path);\n\n this.files.set(canonical, content);\n this.registerAncestorDirectories(canonical);\n }\n\n for (const [command, result] of Object.entries(seed?.commands ?? {})) {\n this.commands.set(command, result);\n }\n }\n\n /** Record every ancestor directory of a path as existing. */\n private registerAncestorDirectories(canonicalPath: string): void {\n for (const ancestor of ancestorsOf(canonicalPath)) {\n this.directories.add(ancestor);\n }\n }\n\n public async readFile(absPath: string): Promise<string> {\n const canonical = canonicalize(absPath);\n const content = this.files.get(canonical);\n\n if (content === undefined) {\n throw new Error(`Mock backend: no such file: ${canonical}`);\n }\n\n return content;\n }\n\n public async writeFile(absPath: string, content: string): Promise<void> {\n const canonical = canonicalize(absPath);\n\n this.files.set(canonical, content);\n this.registerAncestorDirectories(canonical);\n }\n\n public async exists(absPath: string): Promise<boolean> {\n const canonical = canonicalize(absPath);\n\n return this.files.has(canonical) || this.directories.has(canonical);\n }\n\n public async mkdir(absPath: string): Promise<void> {\n const canonical = canonicalize(absPath);\n\n this.directories.add(canonical);\n this.registerAncestorDirectories(canonical);\n }\n\n public async remove(absPath: string): Promise<void> {\n const canonical = canonicalize(absPath);\n const prefix = `${canonical}/`;\n\n // Drop the entry itself and any descendant files/directories — a\n // recursive tree removal, matching the contract's \"file or directory\n // tree\" wording.\n for (const file of [...this.files.keys()]) {\n if (file === canonical || file.startsWith(prefix)) {\n this.files.delete(file);\n }\n }\n\n for (const directory of [...this.directories]) {\n if (directory === canonical || directory.startsWith(prefix)) {\n this.directories.delete(directory);\n }\n }\n }\n\n public async list(absDir: string): Promise<string[]> {\n const canonical = canonicalize(absDir);\n const prefix = canonical === \"/\" ? \"/\" : `${canonical}/`;\n const children = new Set<string>();\n\n const collect = (key: string): void => {\n if (!key.startsWith(prefix) || key === canonical) {\n return;\n }\n\n const remainder = key.slice(prefix.length);\n const nextSlash = remainder.indexOf(\"/\");\n const childName =\n nextSlash === -1 ? remainder : remainder.slice(0, nextSlash);\n\n if (childName.length > 0) {\n children.add(`${prefix}${childName}`);\n }\n };\n\n for (const file of this.files.keys()) {\n collect(file);\n }\n\n for (const directory of this.directories) {\n collect(directory);\n }\n\n return [...children].sort();\n }\n\n public async realpath(absPath: string): Promise<string> {\n return canonicalize(absPath);\n }\n\n public async exec(\n command: string,\n _opts?: WorkspaceBackendExecOptions,\n ): Promise<WorkspaceBackendExecResult> {\n const scripted = this.commands.get(command);\n\n return {\n exitCode: scripted?.exitCode ?? 0,\n stdout: scripted?.stdout ?? \"\",\n stderr: scripted?.stderr ?? \"\",\n timedOut: scripted?.timedOut ?? false,\n };\n }\n}\n\n/**\n * Create an in-memory {@link WorkspaceBackend} for fast, disk-free tests.\n *\n * Every IO method operates on a `Map` of `absolutePath -> content` (with a\n * companion directory set), so reads, writes, existence checks, `mkdir`,\n * recursive `remove`, `list`, and `realpath` all run synchronously in\n * memory with no filesystem access. `exec` is **scripted**: a registered\n * `command -> result` table is consulted by exact command line, and any\n * unregistered command resolves to a successful `0`-exit no-op with empty\n * output.\n *\n * `realpath` is a pure lexical canonicalization (forward slashes,\n * collapsed separators, resolved `.`/`..`) — there are no symlinks in\n * memory — which is exactly what the jail resolver expects.\n *\n * The single positional `seed` accepts either the shorthand\n * `Record<string, string>` of file contents (matching the design's\n * `ai.workspace.mock(seed)` signature) or the richer {@link MockBackendSeed}\n * with both `files` and scripted `commands`.\n *\n * @example\n * // Shorthand: seed files only.\n * const backend = createMockBackend({ \"/srv/app/src/index.ts\": \"export const x = 1;\" });\n * await backend.readFile(\"/srv/app/src/index.ts\"); // \"export const x = 1;\"\n *\n * @example\n * // Full seed: files plus a scripted command.\n * const backend = createMockBackend({\n * files: { \"/srv/app/package.json\": \"{}\" },\n * commands: { \"npm test\": { exitCode: 1, stderr: \"1 failing\" } },\n * });\n * await backend.exec(\"npm test\"); // { exitCode: 1, stderr: \"1 failing\", ... }\n * await backend.exec(\"echo hi\"); // { exitCode: 0, stdout: \"\", ... } (no-op)\n */\nexport function createMockBackend(\n seed?: Record<string, string> | MockBackendSeed,\n): WorkspaceBackend {\n return new MockBackend(normalizeSeed(seed));\n}\n\n/** Coerce the dual-shaped `seed` argument into a {@link MockBackendSeed}. */\nfunction normalizeSeed(\n seed?: Record<string, string> | MockBackendSeed,\n): MockBackendSeed | undefined {\n if (seed === undefined) {\n return undefined;\n }\n\n if (isMockBackendSeed(seed)) {\n return seed;\n }\n\n return { files: seed };\n}\n\n/**\n * Whether `seed` is the structured {@link MockBackendSeed} (has a `files`\n * or `commands` key) rather than the flat `path -> content` shorthand. A\n * plain shorthand map whose only key happens to be named `files` is\n * treated as structured — callers wanting that literal path should use the\n * explicit `{ files: { files: \"...\" } }` form.\n */\nfunction isMockBackendSeed(\n seed: Record<string, string> | MockBackendSeed,\n): seed is MockBackendSeed {\n return (\n typeof (seed as MockBackendSeed).files === \"object\" ||\n typeof (seed as MockBackendSeed).commands === \"object\"\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"mock.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/mock.ts"],"sourcesContent":["import type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n} from \"../contracts/workspace-backend.contract\";\n\n/**\n * A scripted outcome for the mock backend's `exec`. Mirrors\n * {@link WorkspaceBackendExecResult} but every field is optional so a\n * registration can specify only what it cares about — the rest fall back\n * to a successful, empty-output, non-timed-out run.\n */\nexport type MockExecResult = Partial<WorkspaceBackendExecResult>;\n\n/**\n * The two seedable inputs of a mock backend:\n *\n * - `files` — initial file tree as `absolutePath -> content`. Parent\n * directories of each seeded file are implicitly created.\n * - `commands` — scripted `exec` outcomes keyed on the **exact** command\n * line. Any command not registered here resolves to a `0`-exit no-op.\n */\nexport interface MockBackendSeed {\n /** Initial file contents, keyed by absolute path. */\n files?: Record<string, string>;\n /** Scripted `exec` results, keyed by exact command line. */\n commands?: Record<string, MockExecResult>;\n}\n\n/**\n * Normalize an absolute path to a stable in-memory key: forward slashes,\n * collapsed duplicate separators, and resolved `.` / `..` segments. There\n * are no symlinks in memory, so this is a pure lexical canonicalization —\n * exactly what the backend's `realpath` promises.\n */\nfunction canonicalize(absPath: string): string {\n const unified = absPath.replace(/\\\\/g, \"/\");\n // Preserve a leading slash (POSIX root) or a `C:`-style Windows drive\n // prefix; everything else is a normal segment.\n const driveMatch = unified.match(/^([a-zA-Z]:)?\\/?/);\n const prefix = driveMatch ? driveMatch[0] : \"\";\n const rest = unified.slice(prefix.length);\n\n const resolved: string[] = [];\n\n for (const segment of rest.split(\"/\")) {\n if (segment === \"\" || segment === \".\") {\n continue;\n }\n\n if (segment === \"..\") {\n resolved.pop();\n\n continue;\n }\n\n resolved.push(segment);\n }\n\n const joined = resolved.join(\"/\");\n\n // A trailing slash on the prefix means it was absolute; keep it so the\n // result stays absolute even when the body is empty (the root itself).\n const normalizedPrefix = prefix.endsWith(\"/\") ? prefix : `${prefix}/`;\n\n return joined.length > 0 ? `${normalizedPrefix}${joined}` : normalizedPrefix;\n}\n\n/** All ancestor directory keys of a canonical path, root-first. */\nfunction ancestorsOf(canonicalPath: string): string[] {\n const lastSlash = canonicalPath.lastIndexOf(\"/\");\n\n if (lastSlash <= 0) {\n return [];\n }\n\n const parent = canonicalPath.slice(0, lastSlash);\n const result = ancestorsOf(parent);\n\n result.push(parent);\n\n return result;\n}\n\n/**\n * The in-memory executor behind {@link createMockBackend}. Holds the file\n * tree in a `Map`, the directory set in a `Set`, and the scripted command\n * table in a second `Map` — no disk, no child processes, fully\n * deterministic. Construct it via the factory, never directly.\n */\nclass MockBackend implements WorkspaceBackend {\n /** Canonical file path -> content. */\n private readonly files = new Map<string, string>();\n\n /** Canonical directory paths that exist (including implicit parents). */\n private readonly directories = new Set<string>();\n\n /** Exact command line -> scripted outcome. */\n private readonly commands = new Map<string, MockExecResult>();\n\n public constructor(seed?: MockBackendSeed) {\n for (const [path, content] of Object.entries(seed?.files ?? {})) {\n const canonical = canonicalize(path);\n\n this.files.set(canonical, content);\n this.registerAncestorDirectories(canonical);\n }\n\n for (const [command, result] of Object.entries(seed?.commands ?? {})) {\n this.commands.set(command, result);\n }\n }\n\n /** Record every ancestor directory of a path as existing. */\n private registerAncestorDirectories(canonicalPath: string): void {\n for (const ancestor of ancestorsOf(canonicalPath)) {\n this.directories.add(ancestor);\n }\n }\n\n public async readFile(absPath: string): Promise<string> {\n const canonical = canonicalize(absPath);\n const content = this.files.get(canonical);\n\n if (content === undefined) {\n throw new Error(`Mock backend: no such file: ${canonical}`);\n }\n\n return content;\n }\n\n public async writeFile(absPath: string, content: string): Promise<void> {\n const canonical = canonicalize(absPath);\n\n this.files.set(canonical, content);\n this.registerAncestorDirectories(canonical);\n }\n\n public async exists(absPath: string): Promise<boolean> {\n const canonical = canonicalize(absPath);\n\n return this.files.has(canonical) || this.directories.has(canonical);\n }\n\n public async mkdir(absPath: string): Promise<void> {\n const canonical = canonicalize(absPath);\n\n this.directories.add(canonical);\n this.registerAncestorDirectories(canonical);\n }\n\n public async remove(absPath: string): Promise<void> {\n const canonical = canonicalize(absPath);\n const prefix = `${canonical}/`;\n\n // Drop the entry itself and any descendant files/directories — a\n // recursive tree removal, matching the contract's \"file or directory\n // tree\" wording.\n for (const file of [...this.files.keys()]) {\n if (file === canonical || file.startsWith(prefix)) {\n this.files.delete(file);\n }\n }\n\n for (const directory of [...this.directories]) {\n if (directory === canonical || directory.startsWith(prefix)) {\n this.directories.delete(directory);\n }\n }\n }\n\n public async list(absDir: string): Promise<string[]> {\n const canonical = canonicalize(absDir);\n const prefix = canonical === \"/\" ? \"/\" : `${canonical}/`;\n const children = new Set<string>();\n\n const collect = (key: string): void => {\n if (!key.startsWith(prefix) || key === canonical) {\n return;\n }\n\n const remainder = key.slice(prefix.length);\n const nextSlash = remainder.indexOf(\"/\");\n const childName =\n nextSlash === -1 ? remainder : remainder.slice(0, nextSlash);\n\n if (childName.length > 0) {\n children.add(`${prefix}${childName}`);\n }\n };\n\n for (const file of this.files.keys()) {\n collect(file);\n }\n\n for (const directory of this.directories) {\n collect(directory);\n }\n\n return [...children].sort();\n }\n\n public async realpath(absPath: string): Promise<string> {\n return canonicalize(absPath);\n }\n\n public async exec(\n command: string,\n opts?: WorkspaceBackendExecOptions,\n ): Promise<WorkspaceBackendExecResult> {\n // Honour the contract's pre-aborted rule: nothing \"runs\".\n if (opts?.signal?.aborted) {\n return { exitCode: 130, stdout: \"\", stderr: \"\", timedOut: false, cancelled: true };\n }\n\n const scripted = this.commands.get(command);\n\n return {\n exitCode: scripted?.exitCode ?? 0,\n stdout: scripted?.stdout ?? \"\",\n stderr: scripted?.stderr ?? \"\",\n timedOut: scripted?.timedOut ?? false,\n cancelled: scripted?.cancelled ?? false,\n };\n }\n}\n\n/**\n * Create an in-memory {@link WorkspaceBackend} for fast, disk-free tests.\n *\n * Every IO method operates on a `Map` of `absolutePath -> content` (with a\n * companion directory set), so reads, writes, existence checks, `mkdir`,\n * recursive `remove`, `list`, and `realpath` all run synchronously in\n * memory with no filesystem access. `exec` is **scripted**: a registered\n * `command -> result` table is consulted by exact command line, and any\n * unregistered command resolves to a successful `0`-exit no-op with empty\n * output.\n *\n * `realpath` is a pure lexical canonicalization (forward slashes,\n * collapsed separators, resolved `.`/`..`) — there are no symlinks in\n * memory — which is exactly what the jail resolver expects.\n *\n * The single positional `seed` accepts either the shorthand\n * `Record<string, string>` of file contents (matching the design's\n * `ai.workspace.mock(seed)` signature) or the richer {@link MockBackendSeed}\n * with both `files` and scripted `commands`.\n *\n * @example\n * // Shorthand: seed files only.\n * const backend = createMockBackend({ \"/srv/app/src/index.ts\": \"export const x = 1;\" });\n * await backend.readFile(\"/srv/app/src/index.ts\"); // \"export const x = 1;\"\n *\n * @example\n * // Full seed: files plus a scripted command.\n * const backend = createMockBackend({\n * files: { \"/srv/app/package.json\": \"{}\" },\n * commands: { \"npm test\": { exitCode: 1, stderr: \"1 failing\" } },\n * });\n * await backend.exec(\"npm test\"); // { exitCode: 1, stderr: \"1 failing\", ... }\n * await backend.exec(\"echo hi\"); // { exitCode: 0, stdout: \"\", ... } (no-op)\n */\nexport function createMockBackend(\n seed?: Record<string, string> | MockBackendSeed,\n): WorkspaceBackend {\n return new MockBackend(normalizeSeed(seed));\n}\n\n/** Coerce the dual-shaped `seed` argument into a {@link MockBackendSeed}. */\nfunction normalizeSeed(\n seed?: Record<string, string> | MockBackendSeed,\n): MockBackendSeed | undefined {\n if (seed === undefined) {\n return undefined;\n }\n\n if (isMockBackendSeed(seed)) {\n return seed;\n }\n\n return { files: seed };\n}\n\n/**\n * Whether `seed` is the structured {@link MockBackendSeed} (has a `files`\n * or `commands` key) rather than the flat `path -> content` shorthand. A\n * plain shorthand map whose only key happens to be named `files` is\n * treated as structured — callers wanting that literal path should use the\n * explicit `{ files: { files: \"...\" } }` form.\n */\nfunction isMockBackendSeed(\n seed: Record<string, string> | MockBackendSeed,\n): seed is MockBackendSeed {\n return (\n typeof (seed as MockBackendSeed).files === \"object\" ||\n typeof (seed as MockBackendSeed).commands === \"object\"\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EditFileInput, EditFileResult, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ReadFileInput, ReadFileResult, RunShellInput, RunShellResult, RunTestsInput, WorkspaceToolName, WriteFileInput, WriteFileResult } from "./tool-io.type.mjs";
|
|
1
|
+
import { EditFileInput, EditFileResult, ExecOptions, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ReadFileInput, ReadFileResult, RunShellInput, RunShellResult, RunTestsInput, WorkspaceToolName, WriteFileInput, WriteFileResult } from "./tool-io.type.mjs";
|
|
2
2
|
import { WorkspaceBackend, WorkspaceBackendExecOptions, WorkspaceBackendExecResult } from "./workspace-backend.contract.mjs";
|
|
3
3
|
import { WorkspaceOps } from "./workspace-ops.contract.mjs";
|
|
4
4
|
import { WorkspaceBackendType, WorkspacePolicy, WorkspaceReadPolicy, WorkspaceShellPolicy } from "./workspace-policy.type.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region
|
|
1
|
+
//#region ../../packages/ai-workspace/src/contracts/tool-io.type.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* Input/output shapes for the seven workspace tools and the direct
|
|
4
4
|
* methods that back them. These are the wire contracts the agent sees
|
|
@@ -23,20 +23,42 @@ interface ReadFileInput {
|
|
|
23
23
|
/**
|
|
24
24
|
* Result of a read — the requested line window plus the metadata an
|
|
25
25
|
* agent needs to read-before-edit (the `hash` feeds `editFile`'s
|
|
26
|
-
* `expectHash` stale-guard).
|
|
26
|
+
* `expectHash` stale-guard). The `read_file` tool and the `readFile()`
|
|
27
|
+
* direct method return the same shape for the same arguments.
|
|
28
|
+
*
|
|
29
|
+
* `content` is `cat -n` numbered and is bounded by the policy's
|
|
30
|
+
* `read.maxBytes`, measured in UTF-8 bytes of `content` itself (line-number
|
|
31
|
+
* prefixes and newlines included). When the cap cuts the window, the last
|
|
32
|
+
* returned line may be partial — clipped on a character boundary, never
|
|
33
|
+
* mid-character — and `bytesCapped` is `true`.
|
|
27
34
|
*/
|
|
28
35
|
interface ReadFileResult {
|
|
29
|
-
/** The returned file content for the requested window. */
|
|
36
|
+
/** The returned, line-numbered file content for the requested window. */
|
|
30
37
|
content: string;
|
|
31
|
-
/** 1-based first line
|
|
38
|
+
/** 1-based first line requested. */
|
|
32
39
|
startLine: number;
|
|
33
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* 1-based last line included in `content` (possibly partially, when
|
|
42
|
+
* `bytesCapped`). Below `startLine` when the window is empty.
|
|
43
|
+
*/
|
|
34
44
|
endLine: number;
|
|
35
45
|
/** Total line count of the underlying file. */
|
|
36
46
|
totalLines: number;
|
|
37
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* True when anything after `content` was withheld: lines past `endLine`
|
|
49
|
+
* (line `limit`, policy default window or byte cap) or the tail of
|
|
50
|
+
* `endLine` itself (byte cap).
|
|
51
|
+
*/
|
|
38
52
|
truncated: boolean;
|
|
39
|
-
/**
|
|
53
|
+
/** True when `read.maxBytes` cut the window short. */
|
|
54
|
+
bytesCapped: boolean;
|
|
55
|
+
/** UTF-8 byte length of the full file. */
|
|
56
|
+
totalBytes: number;
|
|
57
|
+
/**
|
|
58
|
+
* SHA-256 of the **full** file content at read time — not of the returned
|
|
59
|
+
* window — so it stays valid as an `editFile` `expectHash` whatever window
|
|
60
|
+
* or byte cap was applied.
|
|
61
|
+
*/
|
|
40
62
|
hash: string;
|
|
41
63
|
}
|
|
42
64
|
/**
|
|
@@ -80,6 +102,17 @@ interface WriteFileResult {
|
|
|
80
102
|
/** SHA-256 of the written content. */
|
|
81
103
|
hash: string;
|
|
82
104
|
}
|
|
105
|
+
/** Options for the `exec()` direct method. */
|
|
106
|
+
interface ExecOptions {
|
|
107
|
+
/** Per-call wall-clock cap in ms; overrides the policy `shell.timeoutMs`. */
|
|
108
|
+
timeoutMs?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Cancels this one command. Already aborted: the command is not started.
|
|
111
|
+
* Aborted mid-run: its process tree is killed. Either way the result has
|
|
112
|
+
* `cancelled: true`. The tools pass the agent run's signal here.
|
|
113
|
+
*/
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
}
|
|
83
116
|
/** Input to the `run_shell` tool / `exec()` direct method. */
|
|
84
117
|
interface RunShellInput {
|
|
85
118
|
/** The command line to run; its leading basename is policy-checked. */
|
|
@@ -99,6 +132,12 @@ interface RunShellResult {
|
|
|
99
132
|
truncated: boolean;
|
|
100
133
|
/** True when the command was SIGKILLed for exceeding its timeout. */
|
|
101
134
|
timedOut: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* True when the invocation's `signal` aborted: before start (nothing ran)
|
|
137
|
+
* or mid-run (the process tree was killed). Never true together with
|
|
138
|
+
* `timedOut`.
|
|
139
|
+
*/
|
|
140
|
+
cancelled: boolean;
|
|
102
141
|
}
|
|
103
142
|
/**
|
|
104
143
|
* Input to the `run_tests` tool. Convenience over `run_shell` that runs
|
|
@@ -145,5 +184,5 @@ interface GlobResult {
|
|
|
145
184
|
paths: string[];
|
|
146
185
|
}
|
|
147
186
|
//#endregion
|
|
148
|
-
export { EditFileInput, EditFileResult, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ReadFileInput, ReadFileResult, RunShellInput, RunShellResult, RunTestsInput, WorkspaceToolName, WriteFileInput, WriteFileResult };
|
|
187
|
+
export { EditFileInput, EditFileResult, ExecOptions, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ReadFileInput, ReadFileResult, RunShellInput, RunShellResult, RunTestsInput, WorkspaceToolName, WriteFileInput, WriteFileResult };
|
|
149
188
|
//# sourceMappingURL=tool-io.type.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-io.type.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"tool-io.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/tool-io.type.ts"],"mappings":";;AAYA;;;;AAA6B;AAU7B;;;;;KAVY,iBAAA;;UAUK,aAAA;EAMV;EAJL,IAAA;EAmB6B;EAjB7B,SAAA;EAiB6B;EAf7B,KAAA;AAAA;;;;;;;;AA0CI;AAON;;;;UAlCiB,cAAA;EAsCf;EApCA,OAAA;EAwCA;EAtCA,SAAA;EAwCU;AAAA;AAIZ;;EAvCE,OAAA;EAuC6B;EArC7B,UAAA;EAyCA;;;AAEI;AAIN;EAzCE,SAAA;;EAEA,WAAA;EA2CO;EAzCP,UAAA;EA6C8B;;;;;EAvC9B,IAAA;AAAA;AA6CI;AAIN;;;AAJM,UAtCW,aAAA;EA4Cf;EA1CA,IAAA;EAgDS;EA9CT,SAAA;EA8CoB;EA5CpB,SAAA;EAgD4B;EA9C5B,UAAA;EAgDA;EA9CA,UAAA;AAAA;;UAIe,cAAA;EAgDc;EA9C7B,IAAA;EAkDA;EAhDA,YAAA;EAoDA;EAlDA,IAAA;AAAA;;UAIe,cAAA;EA8DA;EA5Df,IAAA;;EAEA,OAAO;AAAA;AAgET;AAAA,UA5DiB,eAAA;;EAEf,IAAA;EA4DA;EA1DA,YAAA;EA8DA;EA5DA,IAAA;AAAA;AAgEF;AAAA,UA5DiB,WAAA;;EAEf,SAAA;EA4DA;;;;AAII;EA1DJ,MAAA,GAAS,WAAW;AAAA;;UAIL,aAAA;EA4Df;EA1DA,OAAA;EA4DA;EA1DA,SAAS;AAAA;AA8DX;AAAA,UA1DiB,cAAA;;EAEf,QAAA;EA0DO;EAxDP,MAAA;EA4DyB;EA1DzB,MAAA;EA4DK;EA1DL,SAAA;;EAEA,QAAA;;;;;;EAMA,SAAA;AAAA;;;;;;UAQe,aAAA;;EAEf,OAAO;AAAA;;UAIQ,SAAA;;EAEf,OAAA;;EAEA,IAAA;;EAEA,UAAA;AAAA;;UAIe,SAAA;;EAEf,IAAA;;EAEA,IAAA;;EAEA,IAAA;AAAA;;UAIe,UAAA;;EAEf,OAAA,EAAS,SAAS;;EAElB,KAAA;AAAA;;UAIe,SAAA;;EAEf,OAAO;AAAA;;UAIQ,UAAA;;EAEf,KAAK;AAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region
|
|
1
|
+
//#region ../../packages/ai-workspace/src/contracts/workspace-backend.contract.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* Low-level result of a backend command execution. Mirrors the shape the
|
|
4
4
|
* policy-enforced ops layer surfaces as `RunShellResult`, minus the
|
|
@@ -14,6 +14,13 @@ interface WorkspaceBackendExecResult {
|
|
|
14
14
|
stderr: string;
|
|
15
15
|
/** True when the command was killed for exceeding its timeout. */
|
|
16
16
|
timedOut: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* True when the invocation's `signal` aborted — either before the
|
|
19
|
+
* command started (nothing was spawned) or while it ran (the process
|
|
20
|
+
* tree was killed). Mutually exclusive with `timedOut`. Optional so
|
|
21
|
+
* existing custom backends keep compiling; absent reads as `false`.
|
|
22
|
+
*/
|
|
23
|
+
cancelled?: boolean;
|
|
17
24
|
}
|
|
18
25
|
/**
|
|
19
26
|
* Options threaded into a backend `exec` call. The ops layer resolves
|
|
@@ -27,6 +34,15 @@ interface WorkspaceBackendExecOptions {
|
|
|
27
34
|
timeoutMs?: number;
|
|
28
35
|
/** Exact environment for the spawned process (NOT merged with process.env). */
|
|
29
36
|
env?: Record<string, string>;
|
|
37
|
+
/**
|
|
38
|
+
* Per-invocation cancellation. A backend must not start the command when
|
|
39
|
+
* the signal is already aborted, must tear down the whole process tree
|
|
40
|
+
* when it aborts mid-run, must remove its abort listener once the command
|
|
41
|
+
* settles, and reports either case as `cancelled: true` (never a
|
|
42
|
+
* rejection). The signal belongs to ONE call — never cache it on the
|
|
43
|
+
* backend.
|
|
44
|
+
*/
|
|
45
|
+
signal?: AbortSignal;
|
|
30
46
|
}
|
|
31
47
|
/**
|
|
32
48
|
* The swappable executor a workspace runs on — the thin, **policy-agnostic**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace-backend.contract.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"workspace-backend.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-backend.contract.ts"],"mappings":";;AAMA;;;;;UAAiB,0BAAA;EAMf;EAJA,QAAA;EAaA;EAXA,MAAA;EAWS;EATT,MAAA;EAiB0C;EAf1C,QAAA;EA8BoB;;;;;;EAvBpB,SAAA;AAAA;AAuBoB;AAqBtB;;;;AArBsB,UAfL,2BAAA;EA0CU;EAxCzB,GAAA;EA4CyB;EA1CzB,SAAA;EA8C2B;EA5C3B,GAAA,GAAM,MAAA;EAiDK;;;;;;;;EAxCX,MAAA,GAAS,WAAW;AAAA;;;;;;;;;;;;;;;;;;;UAqBL,gBAAA;EAkBb;EAhBF,QAAA,CAAS,OAAA,WAAkB,OAAA;EAiBhB;EAfX,SAAA,CAAU,OAAA,UAAiB,OAAA,WAAkB,OAAA;EAeR;EAbrC,MAAA,CAAO,OAAA,WAAkB,OAAA;;EAEzB,KAAA,CAAM,OAAA,WAAkB,OAAA;;EAExB,MAAA,CAAO,OAAA,WAAkB,OAAA;;EAEzB,IAAA,CAAK,MAAA,WAAiB,OAAA;;EAEtB,QAAA,CAAS,OAAA,WAAkB,OAAA;;EAE3B,IAAA,CACE,OAAA,UACA,IAAA,GAAO,2BAAA,GACN,OAAA,CAAQ,0BAAA;AAAA"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { EditFileInput, EditFileResult, GrepResult, RunShellResult } from "./tool-io.type.mjs";
|
|
1
|
+
import { EditFileInput, EditFileResult, ExecOptions, GrepResult, ReadFileResult, RunShellResult } from "./tool-io.type.mjs";
|
|
2
2
|
|
|
3
|
-
//#region
|
|
3
|
+
//#region ../../packages/ai-workspace/src/contracts/workspace-ops.contract.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* The **policy-enforced** operation layer that sits between the dumb
|
|
6
6
|
* {@link WorkspaceBackend} and the two public callers — the agent-facing
|
|
@@ -22,18 +22,15 @@ import { EditFileInput, EditFileResult, GrepResult, RunShellResult } from "./too
|
|
|
22
22
|
*/
|
|
23
23
|
interface WorkspaceOps {
|
|
24
24
|
/**
|
|
25
|
-
* Read a jailed file, returning
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Read a jailed file, returning a numbered line window, the full file's
|
|
26
|
+
* hash and the window metadata. `offset`/`limit` select a 1-based line
|
|
27
|
+
* window; `read.maxBytes` bounds the UTF-8 bytes of the returned
|
|
28
|
+
* `content` (see {@link ReadFileResult}).
|
|
28
29
|
*/
|
|
29
30
|
readFile(path: string, opts?: {
|
|
30
31
|
offset?: number;
|
|
31
32
|
limit?: number;
|
|
32
|
-
}): Promise<
|
|
33
|
-
content: string;
|
|
34
|
-
hash: string;
|
|
35
|
-
totalLines: number;
|
|
36
|
-
}>;
|
|
33
|
+
}): Promise<ReadFileResult>;
|
|
37
34
|
/** Atomically write full content to a jailed path, returning size + hash. */
|
|
38
35
|
writeFile(path: string, content: string): Promise<{
|
|
39
36
|
hash: string;
|
|
@@ -48,11 +45,10 @@ interface WorkspaceOps {
|
|
|
48
45
|
/**
|
|
49
46
|
* Run a shell command after gating its leading executable basename
|
|
50
47
|
* against the shell allow/deny policy, with the policy environment,
|
|
51
|
-
* timeout, and output byte cap applied.
|
|
48
|
+
* timeout, and output byte cap applied. `signal` cancels this one call
|
|
49
|
+
* (see {@link ExecOptions}).
|
|
52
50
|
*/
|
|
53
|
-
exec(command: string, opts?:
|
|
54
|
-
timeoutMs?: number;
|
|
55
|
-
}): Promise<RunShellResult>;
|
|
51
|
+
exec(command: string, opts?: ExecOptions): Promise<RunShellResult>;
|
|
56
52
|
/** Search jailed file contents for a regex pattern. */
|
|
57
53
|
grep(pattern: string, opts?: {
|
|
58
54
|
glob?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace-ops.contract.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"workspace-ops.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-ops.contract.ts"],"mappings":";;;;;AA4BA;;;;;;;;;;;;;;;;;UAAiB,YAAA;EAiDc;;;;;;EA1C7B,QAAA,CACE,IAAA,UACA,IAAA;IAAS,MAAA;IAAiB,KAAA;EAAA,IACzB,OAAA,CAAQ,cAAA;EAIT;EADF,SAAA,CACE,IAAA,UACA,OAAA,WACC,OAAA;IAAU,IAAA;IAAc,YAAA;EAAA;EAO3B;;;;;EAAA,QAAA,CAAS,KAAA,EAAO,aAAA,GAAgB,OAAA,CAAQ,cAAA;EAQnC;;;;;;EAAL,IAAA,CAAK,OAAA,UAAiB,IAAA,GAAO,WAAA,GAAc,OAAA,CAAQ,cAAA;EAKxC;EAFX,IAAA,CACE,OAAA,UACA,IAAA;IAAS,IAAA;IAAe,UAAA;EAAA,IACvB,OAAA,CAAQ,UAAA;EAGX;EAAA,IAAA,CAAK,OAAA,WAAkB,OAAA;EAAA;EAGvB,MAAA,CAAO,IAAA,WAAe,OAAA;EAAf;EAGP,KAAA,CAAM,IAAA,WAAe,OAAA;EAArB;EAGA,MAAA,CAAO,IAAA,WAAe,OAAA;AAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region
|
|
1
|
+
//#region ../../packages/ai-workspace/src/contracts/workspace-policy.type.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* The executor a workspace runs on. `"local"` uses `@warlock.js/fs` +
|
|
4
4
|
* `node:child_process` against the real filesystem under `cwd`;
|
|
@@ -46,9 +46,16 @@ interface WorkspaceShellPolicy {
|
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* Read sub-policy controlling how much of a file a single read returns.
|
|
49
|
+
* Applies equally to the `read_file` tool and the `readFile()` direct method.
|
|
49
50
|
*/
|
|
50
51
|
interface WorkspaceReadPolicy {
|
|
51
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Hard cap on the UTF-8 bytes of the `content` a single read returns,
|
|
54
|
+
* line-number prefixes and newlines included. A window that would exceed
|
|
55
|
+
* it is cut — mid-line if needed, always on a character boundary — and
|
|
56
|
+
* the result reports `bytesCapped: true`. Absent ⇒ no byte cap. The
|
|
57
|
+
* whole file is still read and hashed; this bounds what is returned.
|
|
58
|
+
*/
|
|
52
59
|
maxBytes?: number;
|
|
53
60
|
/** Default number of lines a read returns when no explicit limit is given. */
|
|
54
61
|
defaultLines?: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":";;AAQA;;;;AAAgC;AAqBhC;;KArBY,oBAAA;;;;;;;;;;AAoCA;AAOZ;;;;AAUc;AAsBd;;;;UAtDiB,oBAAA;EAkEL;EAhEV,KAAA;EAgE8B;EA9D9B,IAAA;EAsDA;EApDA,SAAA;EAwDA;EAtDA,cAAA;EAwDA;EAtDA,GAAA,GAAM,MAAM;EAwDZ;;;AAA8B;EAnD9B,UAAA;AAAA;;;;;UAOe,mBAAA;;;;;;;;EAQf,QAAA;;EAEA,YAAY;AAAA;;;;;;;;;;;;;;;;;;;;UAsBG,eAAA;;EAEf,GAAA;;EAEA,UAAA;;EAEA,SAAA;;EAEA,KAAA,GAAQ,oBAAA;;EAER,IAAA,GAAO,mBAAA;;EAEP,OAAA,GAAU,oBAAA;AAAA"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { EditFileInput, EditFileResult, GrepResult, RunShellResult, WorkspaceToolName } from "./tool-io.type.mjs";
|
|
1
|
+
import { EditFileInput, EditFileResult, ExecOptions, GrepResult, ReadFileResult, RunShellResult, WorkspaceToolName } from "./tool-io.type.mjs";
|
|
2
2
|
import { WorkspacePolicy } from "./workspace-policy.type.mjs";
|
|
3
3
|
import { ToolContract } from "@warlock.js/ai";
|
|
4
4
|
|
|
5
|
-
//#region
|
|
5
|
+
//#region ../../packages/ai-workspace/src/contracts/workspace.contract.d.ts
|
|
6
6
|
/**
|
|
7
7
|
* The agent-facing tool surface of a workspace. Each factory returns a
|
|
8
8
|
* {@link ToolContract} ready to hand to `ai.agent({ tools })`. `all()`
|
|
@@ -77,18 +77,15 @@ interface Workspace {
|
|
|
77
77
|
/** Agent-facing tool factories. */
|
|
78
78
|
readonly tools: WorkspaceTools;
|
|
79
79
|
/**
|
|
80
|
-
* Read a jailed file. `offset`/`limit` select a 1-based line window
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* Read a jailed file. `offset`/`limit` select a 1-based line window and
|
|
81
|
+
* `read.maxBytes` bounds the returned bytes; returns the numbered content,
|
|
82
|
+
* the full file's SHA-256 `hash` (for read-before-edit), and the window
|
|
83
|
+
* metadata — the same shape the `read_file` tool returns.
|
|
83
84
|
*/
|
|
84
85
|
readFile(path: string, opts?: {
|
|
85
86
|
offset?: number;
|
|
86
87
|
limit?: number;
|
|
87
|
-
}): Promise<
|
|
88
|
-
content: string;
|
|
89
|
-
hash: string;
|
|
90
|
-
totalLines: number;
|
|
91
|
-
}>;
|
|
88
|
+
}): Promise<ReadFileResult>;
|
|
92
89
|
/** Atomically write full content to a jailed path. */
|
|
93
90
|
writeFile(path: string, content: string): Promise<{
|
|
94
91
|
hash: string;
|
|
@@ -96,10 +93,12 @@ interface Workspace {
|
|
|
96
93
|
}>;
|
|
97
94
|
/** Apply an exact-string edit under the read-before-edit stale guard. */
|
|
98
95
|
editFile(input: EditFileInput): Promise<EditFileResult>;
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Run a policy-gated shell command and capture its outcome. `signal`
|
|
98
|
+
* cancels this one call: not started when already aborted, process tree
|
|
99
|
+
* killed when it aborts mid-run, `cancelled: true` either way.
|
|
100
|
+
*/
|
|
101
|
+
exec(command: string, opts?: ExecOptions): Promise<RunShellResult>;
|
|
103
102
|
/** Search jailed file contents for a regex pattern. */
|
|
104
103
|
grep(pattern: string, opts?: {
|
|
105
104
|
glob?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace.contract.d.mts","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"workspace.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace.contract.ts"],"mappings":";;;;;;;AAsBA;;;;;;;;UAAiB,cAAA;EAYqB;EAVpC,GAAA,IAAO,YAAA;EAcyB;EAZhC,IAAA,IAAQ,KAAA,EAAO,iBAAA,KAAsB,YAAA;EAcO;EAZ5C,QAAA,CAAS,IAAA;IAAS,IAAA;EAAA,IAAkB,YAAA;EAFpC;EAIA,QAAA,CAAS,IAAA;IAAS,IAAA;EAAA,IAAkB,YAAA;EAFpC;EAIA,SAAA,CAAU,IAAA;IAAS,IAAA;EAAA,IAAkB,YAAA;EAFrC;EAIA,QAAA,CAAS,IAAA;IAAS,IAAA;EAAA,IAAkB,YAAA;EAFpC;EAIA,QAAA,CAAS,IAAA;IAAS,IAAA;IAAe,OAAA;EAAA,IAAqB,YAAA;EAFpC;EAIlB,IAAA,CAAK,IAAA;IAAS,IAAA;EAAA,IAAkB,YAAA;EAFd;EAIlB,IAAA,CAAK,IAAA;IAAS,IAAA;EAAA,IAAkB,YAAA;AAAA;;;;;;;;;AAAY;AA0B9C;;;;;;;;;;;;;;UAAiB,SAAA;EAsCZ;EAAA,SApCM,MAAA,EAAQ,eAAA;EA0CK;EAAA,SAvCb,KAAA,EAAO,cAAA;EA6CM;;;;;;EArCtB,QAAA,CACE,IAAA,UACA,IAAA;IAAS,MAAA;IAAiB,KAAA;EAAA,IACzB,OAAA,CAAQ,cAAA;EAFT;EAKF,SAAA,CACE,IAAA,UACA,OAAA,WACC,OAAA;IAAU,IAAA;IAAc,YAAA;EAAA;EANhB;EASX,QAAA,CAAS,KAAA,EAAO,aAAA,GAAgB,OAAA,CAAQ,cAAA;EALtC;;;;;EAYF,IAAA,CAAK,OAAA,UAAiB,IAAA,GAAO,WAAA,GAAc,OAAA,CAAQ,cAAA;EAPnC;EAUhB,IAAA,CACE,OAAA,UACA,IAAA;IAAS,IAAA;IAAe,UAAA;EAAA,IACvB,OAAA,CAAQ,UAAA;EANN;EASL,IAAA,CAAK,OAAA,WAAkB,OAAA;EATD;EAYtB,MAAA,CAAO,IAAA,WAAe,OAAA;EAZ6B;EAenD,KAAA,CAAM,IAAA,WAAe,OAAA;EAXnB;EAcF,MAAA,CAAO,IAAA,WAAe,OAAA;EAbI;;;;;EAoB1B,QAAA,IAAY,SAAA;EAhBW;;;;;EAuBvB,KAAA,CAAM,MAAA,WAAiB,SAAA;AAAA"}
|