@warlock.js/ai-workspace 5.45.0 → 5.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/cjs/index.cjs +514 -54
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts.map +1 -1
  6. package/esm/backends/local.mjs +24 -1
  7. package/esm/backends/local.mjs.map +1 -1
  8. package/esm/contracts/index.d.mts +4 -4
  9. package/esm/contracts/tool-io.type.d.mts +6 -0
  10. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  11. package/esm/contracts/workspace-backend.contract.d.mts +20 -0
  12. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  13. package/esm/contracts/workspace-edit.type.d.mts +30 -0
  14. package/esm/contracts/workspace-edit.type.d.mts.map +1 -1
  15. package/esm/contracts/workspace-ops.contract.d.mts +7 -2
  16. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  17. package/esm/contracts/workspace-policy.type.d.mts +23 -0
  18. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  19. package/esm/contracts/workspace.contract.d.mts +4 -1
  20. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  21. package/esm/hooks/shell-output.mjs +113 -0
  22. package/esm/hooks/shell-output.mjs.map +1 -0
  23. package/esm/index.d.mts +4 -4
  24. package/esm/ops.d.mts.map +1 -1
  25. package/esm/ops.mjs +108 -40
  26. package/esm/ops.mjs.map +1 -1
  27. package/esm/policy/shell-check.d.mts +2 -0
  28. package/esm/policy/shell-check.d.mts.map +1 -1
  29. package/esm/policy/shell-check.mjs +59 -5
  30. package/esm/policy/shell-check.mjs.map +1 -1
  31. package/esm/search/walk.mjs +209 -0
  32. package/esm/search/walk.mjs.map +1 -0
  33. package/esm/shell-jobs.d.mts +1 -1
  34. package/esm/shell-jobs.d.mts.map +1 -1
  35. package/esm/shell-jobs.mjs +1 -1
  36. package/esm/shell-jobs.mjs.map +1 -1
  37. package/esm/tools/glob.mjs +5 -4
  38. package/esm/tools/glob.mjs.map +1 -1
  39. package/esm/tools/grep.mjs +3 -2
  40. package/esm/tools/grep.mjs.map +1 -1
  41. package/esm/tools/run-shell.d.mts.map +1 -1
  42. package/esm/tools/run-shell.mjs +2 -1
  43. package/esm/tools/run-shell.mjs.map +1 -1
  44. package/esm/workspace.mjs +2 -2
  45. package/esm/workspace.mjs.map +1 -1
  46. package/llms-full.txt +36 -17
  47. package/package.json +3 -3
  48. package/skills/use-a-workspace/SKILL.md +36 -17
@@ -1 +1 @@
1
- {"version":3,"file":"local.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/local.ts"],"mappings":";;;;;;;;;;;;;;;;;wBA6lBgB,sBAAsB"}
1
+ {"version":3,"file":"local.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/local.ts"],"mappings":";;;;;;;;;;;;;;;;;wBA0oBgB,sBAAsB"}
@@ -1,5 +1,6 @@
1
1
  import { tokenizeCommand } from "../policy/tokenize-command.mjs";
2
- import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { lstat, readdir, realpath } from "node:fs/promises";
3
4
  import { fs } from "@warlock.js/fs";
4
5
  import { spawn } from "node:child_process";
5
6
  import { platform } from "node:process";
@@ -233,6 +234,13 @@ var LocalBackend = class {
233
234
  async list(absDir) {
234
235
  return fs.dirs.list(absDir);
235
236
  }
237
+ /** List children with their kinds from one directory read (symlinks not followed). */
238
+ async listTyped(absDir) {
239
+ return (await readdir(absDir, { withFileTypes: true })).map((entry) => ({
240
+ path: path.join(absDir, entry.name),
241
+ type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other"
242
+ }));
243
+ }
236
244
  /**
237
245
  * Describe a path without following a final symlink (`lstat` — like
238
246
  * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
@@ -333,6 +341,10 @@ var LocalBackend = class {
333
341
  settled = true;
334
342
  if (timer !== void 0) clearTimeout(timer);
335
343
  signal?.removeEventListener("abort", onAbort);
344
+ for (const stream of ["stdout", "stderr"]) {
345
+ const tail = liveDecoders[stream].end();
346
+ if (tail !== "") opts.onOutput?.(stream, tail);
347
+ }
336
348
  resolve({
337
349
  exitCode,
338
350
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
@@ -341,11 +353,22 @@ var LocalBackend = class {
341
353
  cancelled
342
354
  });
343
355
  };
356
+ const liveDecoders = {
357
+ stdout: new StringDecoder("utf8"),
358
+ stderr: new StringDecoder("utf8")
359
+ };
360
+ const live = (stream, chunk) => {
361
+ if (opts.onOutput === void 0 || settled) return;
362
+ const text = liveDecoders[stream].write(chunk);
363
+ if (text !== "") opts.onOutput(stream, text);
364
+ };
344
365
  child.stdout?.on("data", (chunk) => {
345
366
  stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);
367
+ live("stdout", chunk);
346
368
  });
347
369
  child.stderr?.on("data", (chunk) => {
348
370
  stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);
371
+ live("stderr", chunk);
349
372
  });
350
373
  child.on("error", () => {
351
374
  settle(127);
@@ -1 +1 @@
1
- {"version":3,"file":"local.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/local.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { lstat, realpath } from \"node:fs/promises\";\nimport { platform } from \"node:process\";\nimport { StringDecoder } from \"node:string_decoder\";\nimport { fs } from \"@warlock.js/fs\";\nimport { tokenizeCommand } from \"../policy/tokenize-command\";\nimport type { WorkspaceShellInterpreter } from \"../contracts/workspace-policy.type\";\nimport type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n WorkspaceBackendProcess,\n WorkspaceBackendStartOptions,\n WorkspaceBackendStat,\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(argv: string[]): { 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/** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */\nconst POSIX_SHELL = \"/bin/sh\";\n\n/** How to spawn a command line, or why it cannot be spawned. */\nexport type SpawnPlan =\n | { file: string; args: string[]; windowsVerbatimArguments: boolean }\n | { refused: string };\n\n/**\n * Work out the spawn for a command line.\n *\n * - argv mode: tokenize (no shell semantics) and spawn the argv directly —\n * on Windows through an individually-quoted `cmd.exe` wrapper.\n * - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c \"<line>\"`\n * on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,\n * `/s` keeps the line verbatim inside the outer quotes). The ops layer has\n * already checked every command in the line.\n */\nexport function planSpawn(\n command: string,\n interpreter: WorkspaceShellInterpreter,\n interpreterPath?: string,\n hostPlatform: NodeJS.Platform = platform,\n): SpawnPlan {\n if (interpreter === \"powershell\") {\n return {\n file: interpreterPath ?? (hostPlatform === \"win32\" ? \"powershell.exe\" : \"pwsh\"),\n args: [\"-NoLogo\", \"-NoProfile\", \"-NonInteractive\", \"-Command\", command],\n windowsVerbatimArguments: false,\n };\n }\n\n if (interpreter === \"bash\") {\n return {\n file: interpreterPath ?? \"bash\",\n args: [\"-c\", command],\n windowsVerbatimArguments: false,\n };\n }\n\n if (interpreter === \"cmd\" || (interpreter === \"sh\" && hostPlatform === \"win32\")) {\n return {\n file: interpreterPath ?? process.env.ComSpec ?? \"cmd.exe\",\n args: [\"/d\", \"/v:off\", \"/s\", \"/c\", `\"${command}\"`],\n windowsVerbatimArguments: true,\n };\n }\n\n if (interpreter === \"sh\") {\n return {\n file: interpreterPath ?? POSIX_SHELL,\n args: [\"-c\", command],\n windowsVerbatimArguments: false,\n };\n }\n\n const argv = tokenizeCommand(command);\n const executable = argv?.[0];\n\n if (argv === null || executable === undefined) {\n return {\n refused:\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\n if (platform === \"win32\") {\n const invocation = toWin32CmdInvocation(argv);\n\n if (invocation === null) {\n return {\n refused:\n 'Command was not executed: on Windows, arguments containing \", %, ' +\n \"or newlines cannot be passed to cmd.exe safely.\",\n };\n }\n\n return { ...invocation, windowsVerbatimArguments: true };\n }\n\n return { file: executable, args: argv.slice(1), windowsVerbatimArguments: false };\n}\n\n/**\n * Kill functions of every background process still running, so they can\n * be reaped if the Node process exits without the workspace being disposed\n * (detached process groups would otherwise outlive it).\n */\nconst liveBackgroundKills = new Set<() => void>();\n\nlet exitHookInstalled = false;\n\n/** Track a live background tree; installs (once) the exit hook that kills them. */\nfunction trackBackgroundProcess(kill: () => void): () => void {\n liveBackgroundKills.add(kill);\n\n if (!exitHookInstalled) {\n exitHookInstalled = true;\n process.on(\"exit\", () => {\n for (const killTreeNow of liveBackgroundKills) {\n killTreeNow();\n }\n });\n }\n\n return () => {\n liveBackgroundKills.delete(kill);\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 * Describe a path without following a final symlink (`lstat` — like\n * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).\n */\n public async stat(absPath: string): Promise<WorkspaceBackendStat> {\n const stats = await lstat(absPath);\n const type = stats.isSymbolicLink()\n ? \"symlink\"\n : stats.isDirectory()\n ? \"directory\"\n : stats.isFile()\n ? \"file\"\n : \"other\";\n\n return { type, size: stats.size };\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. In argv mode (the default) the\n * command line is tokenized into an argv (quotes respected, NO shell\n * semantics — see `tokenizeCommand`) and spawned **without a shell**, so\n * metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never\n * chain extra commands past the ops layer's allowlist; a command they\n * appear unquoted in is refused with exit code 127. On Windows the argv\n * runs 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 * With `interpreter: \"sh\"` the line runs through `/bin/sh -c` (or\n * `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has\n * checked every command in it.\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 plan = planSpawn(command, opts.interpreter ?? \"none\", opts.interpreterPath);\n\n if (\"refused\" in plan) {\n refuse(plan.refused);\n\n return;\n }\n\n const child = spawn(plan.file, plan.args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments: plan.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 * Start a long-running command and return at once. Spawned like\n * {@link exec} (same argv/shell planning, own process group on POSIX), with\n * stdin closed. Output is decoded per stream with a `StringDecoder`, so a\n * chunk never ends inside a character. `kill()` kills the whole tree; a\n * still-running tree is also killed when the Node process exits.\n */\n public start(command: string, opts: WorkspaceBackendStartOptions): WorkspaceBackendProcess {\n const plan = planSpawn(command, opts.interpreter ?? \"none\", opts.interpreterPath);\n\n if (\"refused\" in plan) {\n opts.onOutput(\"stderr\", plan.refused);\n\n return { done: Promise.resolve({ exitCode: 127 }), kill: () => {} };\n }\n\n const child = spawn(plan.file, plan.args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments: plan.windowsVerbatimArguments,\n detached: platform !== \"win32\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n const stdoutDecoder = new StringDecoder(\"utf8\");\n const stderrDecoder = new StringDecoder(\"utf8\");\n let finished = false;\n\n const kill = (): void => {\n if (!finished) {\n killTree(child.pid, child);\n }\n };\n\n const untrack = trackBackgroundProcess(kill);\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n const text = stdoutDecoder.write(chunk);\n\n if (text !== \"\") {\n opts.onOutput(\"stdout\", text);\n }\n });\n\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n const text = stderrDecoder.write(chunk);\n\n if (text !== \"\") {\n opts.onOutput(\"stderr\", text);\n }\n });\n\n const done = new Promise<{ exitCode: number }>((resolve) => {\n const finish = (exitCode: number): void => {\n if (finished) {\n return;\n }\n\n finished = true;\n untrack();\n\n const stdoutTail = stdoutDecoder.end();\n const stderrTail = stderrDecoder.end();\n\n if (stdoutTail !== \"\") {\n opts.onOutput(\"stdout\", stdoutTail);\n }\n\n if (stderrTail !== \"\") {\n opts.onOutput(\"stderr\", stderrTail);\n }\n\n resolve({ exitCode });\n };\n\n child.on(\"error\", (error) => {\n opts.onOutput(\"stderr\", `Command could not be started: ${error.message}`);\n finish(127);\n });\n\n child.on(\"close\", (code, signal) => {\n finish(code === null ? 128 + (signal === \"SIGKILL\" ? 9 : 1) : code);\n });\n });\n\n return { ...(child.pid !== undefined ? { pid: child.pid } : {}), done, kill };\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":";;;;;;;;;;;;;;;AAuBA,MAAM,mBAAmB;;;;;;AAOzB,SAAS,WAAW,QAAkB,OAAe,OAAuB;CAC1E,IAAI,SAAS,kBACX,OAAO;CAGT,MAAM,YAAY,mBAAmB;CAErC,IAAI,MAAM,UAAU,WAAW;EAC7B,OAAO,KAAK,KAAK;EAEjB,OAAO,QAAQ,MAAM;CACvB;CAEA,OAAO,KAAK,MAAM,SAAS,GAAG,SAAS,CAAC;CAExC,OAAO;AACT;;;;;;;;;;;;;AAcA,SAAS,SAAS,KAAyB,OAAwD;CACjG,IAAI,QAAQ,QAAW;EACrB,MAAM,KAAK,SAAS;EAEpB;CACF;CAEA,IAAI,aAAa,SAAS;EAMxB,AALe,MAAM,YAAY;GAAC;GAAQ,OAAO,GAAG;GAAG;GAAM;EAAI,GAAG,EAAE,aAAa,KAAK,CAKnF,CAAC,CAAC,GAAG,eAAe;GACvB,MAAM,KAAK,SAAS;EACtB,CAAC;EAED;CACF;CAEA,IAAI;EACF,QAAQ,KAAK,CAAC,KAAK,SAAS;CAC9B,QAAQ;EAEN,MAAM,KAAK,SAAS;CACtB;AACF;;;;;;;;AASA,MAAM,wBAAwB;;;;;;;;;;;;;AAc9B,SAAS,qBAAqB,MAAyD;CACrF,IAAI,KAAK,MAAM,YAAY,sBAAsB,KAAK,OAAO,CAAC,GAC5D,OAAO;CAGT,MAAM,cAAc,KAAK,KAAK,YAAY,IAAI,QAAQ,EAAE,CAAC,CAAC,KAAK,GAAG;CAElE,OAAO;EACL,MAAM,QAAQ,IAAI,WAAW;EAC7B,MAAM;GAAC;GAAM;GAAM;GAAM,IAAI,YAAY;EAAE;CAC7C;AACF;;AAGA,MAAM,cAAc;;;;;;;;;;;AAiBpB,SAAgB,UACd,SACA,aACA,iBACA,eAAgC,UACrB;CACX,IAAI,gBAAgB,cAClB,OAAO;EACL,MAAM,oBAAoB,iBAAiB,UAAU,mBAAmB;EACxE,MAAM;GAAC;GAAW;GAAc;GAAmB;GAAY;EAAO;EACtE,0BAA0B;CAC5B;CAGF,IAAI,gBAAgB,QAClB,OAAO;EACL,MAAM,mBAAmB;EACzB,MAAM,CAAC,MAAM,OAAO;EACpB,0BAA0B;CAC5B;CAGF,IAAI,gBAAgB,SAAU,gBAAgB,QAAQ,iBAAiB,SACrE,OAAO;EACL,MAAM,mBAAmB,QAAQ,IAAI,WAAW;EAChD,MAAM;GAAC;GAAM;GAAU;GAAM;GAAM,IAAI,QAAQ;EAAE;EACjD,0BAA0B;CAC5B;CAGF,IAAI,gBAAgB,MAClB,OAAO;EACL,MAAM,mBAAmB;EACzB,MAAM,CAAC,MAAM,OAAO;EACpB,0BAA0B;CAC5B;CAGF,MAAM,OAAO,gBAAgB,OAAO;CACpC,MAAM,aAAa,OAAO;CAE1B,IAAI,SAAS,QAAQ,eAAe,QAClC,OAAO,EACL,SACE,mQAIJ;CAGF,IAAI,aAAa,SAAS;EACxB,MAAM,aAAa,qBAAqB,IAAI;EAE5C,IAAI,eAAe,MACjB,OAAO,EACL,SACE,oHAEJ;EAGF,OAAO;GAAE,GAAG;GAAY,0BAA0B;EAAK;CACzD;CAEA,OAAO;EAAE,MAAM;EAAY,MAAM,KAAK,MAAM,CAAC;EAAG,0BAA0B;CAAM;AAClF;;;;;;AAOA,MAAM,sCAAsB,IAAI,IAAgB;AAEhD,IAAI,oBAAoB;;AAGxB,SAAS,uBAAuB,MAA8B;CAC5D,oBAAoB,IAAI,IAAI;CAE5B,IAAI,CAAC,mBAAmB;EACtB,oBAAoB;EACpB,QAAQ,GAAG,cAAc;GACvB,KAAK,MAAM,eAAe,qBACxB,YAAY;EAEhB,CAAC;CACH;CAEA,aAAa;EACX,oBAAoB,OAAO,IAAI;CACjC;AACF;;;;;;;;;;;AAYA,IAAM,eAAN,MAA+C;;CAE7C,MAAa,SAAS,SAAkC;EACtD,OAAO,GAAG,MAAM,IAAI,OAAO;CAC7B;;;;;;CAOA,MAAa,UAAU,SAAiB,SAAgC;EACtE,MAAM,GAAG,MAAM,IAAI,SAAS,SAAS,EAAE,QAAQ,KAAK,CAAC;CACvD;;CAGA,MAAa,OAAO,SAAmC;EACrD,OAAO,GAAG,OAAO,OAAO;CAC1B;;CAGA,MAAa,MAAM,SAAgC;EACjD,MAAM,GAAG,KAAK,OAAO,OAAO;CAC9B;;;;;;;CAQA,MAAa,OAAO,SAAgC;EAClD,IAAI,cAAc;EAElB,IAAI;GACF,eAAe,MAAM,GAAG,MAAM,MAAM,OAAO,EAAC,CAAE,SAAS;EACzD,SAAS,OAAO;GACd,IAAK,OAAiC,SAAS,UAC7C;GAGF,MAAM;EACR;EAEA,IAAI,aAAa;GACf,MAAM,GAAG,KAAK,OAAO,OAAO;GAE5B;EACF;EAEA,MAAM,GAAG,MAAM,OAAO,OAAO;CAC/B;;CAGA,MAAa,KAAK,QAAmC;EACnD,OAAO,GAAG,KAAK,KAAK,MAAM;CAC5B;;;;;CAMA,MAAa,KAAK,SAAgD;EAChE,MAAM,QAAQ,MAAM,MAAM,OAAO;EASjC,OAAO;GAAE,MARI,MAAM,eAAe,IAC9B,YACA,MAAM,YAAY,IAChB,cACA,MAAM,OAAO,IACX,SACA;GAEO,MAAM,MAAM;EAAK;CAClC;;;;;;;CAQA,MAAa,SAAS,SAAkC;EACtD,OAAO,SAAS,OAAO;CACzB;;;;;;;;;;;;;;;;;;;;;;;;;CA0BA,AAAO,KACL,SACA,OAAoC,CAAC,GACA;EACrC,OAAO,IAAI,SAAqC,YAAY;GAC1D,MAAM,UAAU,WACd,QAAQ;IAAE,UAAU;IAAK,QAAQ;IAAI;IAAQ,UAAU;GAAM,CAAC;GAEhE,MAAM,SAAS,KAAK;GAGpB,IAAI,QAAQ,SAAS;IACnB,QAAQ;KACN,UAAU;KACV,QAAQ;KACR,QAAQ;KACR,UAAU;KACV,WAAW;IACb,CAAC;IAED;GACF;GAEA,MAAM,OAAO,UAAU,SAAS,KAAK,eAAe,QAAQ,KAAK,eAAe;GAEhF,IAAI,aAAa,MAAM;IACrB,OAAO,KAAK,OAAO;IAEnB;GACF;GAEA,MAAM,QAAQ,MAAM,KAAK,MAAM,KAAK,MAAM;IACxC,KAAK,KAAK;IACV,KAAK,KAAK;IACV,OAAO;IACP,aAAa;IACb,0BAA0B,KAAK;IAI/B,UAAU,aAAa;GACzB,CAAC;GAED,MAAM,eAAyB,CAAC;GAChC,MAAM,eAAyB,CAAC;GAChC,IAAI,cAAc;GAClB,IAAI,cAAc;GAClB,IAAI,WAAW;GACf,IAAI,YAAY;GAChB,IAAI,UAAU;GAGd,IAAI,SAAS;GAIb,MAAM,QACJ,KAAK,cAAc,UAAa,KAAK,YAAY,IAC7C,iBAAiB;IACf,IAAI,WACF;IAGF,WAAW;IACX,SAAS,MAAM,KAAK,KAAK;GAC3B,GAAG,KAAK,SAAS,IACjB;GAEN,MAAM,gBAAsB;IAC1B,IAAI,WAAW,UACb;IAMF,IAAI,CAAC,QACH,YAAY;IAGd,SAAS,MAAM,KAAK,KAAK;GAC3B;GAKA,QAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;GAEzD,MAAM,UAAU,aAAqB;IACnC,IAAI,SACF;IAGF,UAAU;IAEV,IAAI,UAAU,QACZ,aAAa,KAAK;IAGpB,QAAQ,oBAAoB,SAAS,OAAO;IAE5C,QAAQ;KACN;KACA,QAAQ,OAAO,OAAO,YAAY,CAAC,CAAC,SAAS,MAAM;KACnD,QAAQ,OAAO,OAAO,YAAY,CAAC,CAAC,SAAS,MAAM;KACnD;KACA;IACF,CAAC;GACH;GAEA,MAAM,QAAQ,GAAG,SAAS,UAAkB;IAC1C,cAAc,WAAW,cAAc,aAAa,KAAK;GAC3D,CAAC;GAED,MAAM,QAAQ,GAAG,SAAS,UAAkB;IAC1C,cAAc,WAAW,cAAc,aAAa,KAAK;GAC3D,CAAC;GAKD,MAAM,GAAG,eAAe;IACtB,OAAO,GAAG;GACZ,CAAC;GAED,MAAM,GAAG,cAAc;IACrB,SAAS;GACX,CAAC;GAED,MAAM,GAAG,UAAU,MAAM,WAAW;IAIlC,IAAI,SAAS,MAAM;KAEjB,OAAO,OADc,WAAW,YAAY,IAAI,EACvB;KAEzB;IACF;IAEA,OAAO,IAAI;GACb,CAAC;EACH,CAAC;CACH;;;;;;;;CASA,AAAO,MAAM,SAAiB,MAA6D;EACzF,MAAM,OAAO,UAAU,SAAS,KAAK,eAAe,QAAQ,KAAK,eAAe;EAEhF,IAAI,aAAa,MAAM;GACrB,KAAK,SAAS,UAAU,KAAK,OAAO;GAEpC,OAAO;IAAE,MAAM,QAAQ,QAAQ,EAAE,UAAU,IAAI,CAAC;IAAG,YAAY,CAAC;GAAE;EACpE;EAEA,MAAM,QAAQ,MAAM,KAAK,MAAM,KAAK,MAAM;GACxC,KAAK,KAAK;GACV,KAAK,KAAK;GACV,OAAO;GACP,aAAa;GACb,0BAA0B,KAAK;GAC/B,UAAU,aAAa;GACvB,OAAO;IAAC;IAAU;IAAQ;GAAM;EAClC,CAAC;EAED,MAAM,gBAAgB,IAAI,cAAc,MAAM;EAC9C,MAAM,gBAAgB,IAAI,cAAc,MAAM;EAC9C,IAAI,WAAW;EAEf,MAAM,aAAmB;GACvB,IAAI,CAAC,UACH,SAAS,MAAM,KAAK,KAAK;EAE7B;EAEA,MAAM,UAAU,uBAAuB,IAAI;EAE3C,MAAM,QAAQ,GAAG,SAAS,UAAkB;GAC1C,MAAM,OAAO,cAAc,MAAM,KAAK;GAEtC,IAAI,SAAS,IACX,KAAK,SAAS,UAAU,IAAI;EAEhC,CAAC;EAED,MAAM,QAAQ,GAAG,SAAS,UAAkB;GAC1C,MAAM,OAAO,cAAc,MAAM,KAAK;GAEtC,IAAI,SAAS,IACX,KAAK,SAAS,UAAU,IAAI;EAEhC,CAAC;EAED,MAAM,OAAO,IAAI,SAA+B,YAAY;GAC1D,MAAM,UAAU,aAA2B;IACzC,IAAI,UACF;IAGF,WAAW;IACX,QAAQ;IAER,MAAM,aAAa,cAAc,IAAI;IACrC,MAAM,aAAa,cAAc,IAAI;IAErC,IAAI,eAAe,IACjB,KAAK,SAAS,UAAU,UAAU;IAGpC,IAAI,eAAe,IACjB,KAAK,SAAS,UAAU,UAAU;IAGpC,QAAQ,EAAE,SAAS,CAAC;GACtB;GAEA,MAAM,GAAG,UAAU,UAAU;IAC3B,KAAK,SAAS,UAAU,iCAAiC,MAAM,SAAS;IACxE,OAAO,GAAG;GACZ,CAAC;GAED,MAAM,GAAG,UAAU,MAAM,WAAW;IAClC,OAAO,SAAS,OAAO,OAAO,WAAW,YAAY,IAAI,KAAK,IAAI;GACpE,CAAC;EACH,CAAC;EAED,OAAO;GAAE,GAAI,MAAM,QAAQ,SAAY,EAAE,KAAK,MAAM,IAAI,IAAI,CAAC;GAAI;GAAM;EAAK;CAC9E;AACF;;;;;;;;;;;;;;;;AAiBA,SAAgB,qBAAuC;CACrD,OAAO,IAAI,aAAa;AAC1B"}
1
+ {"version":3,"file":"local.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/backends/local.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { lstat, readdir, realpath } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { platform } from \"node:process\";\nimport { StringDecoder } from \"node:string_decoder\";\nimport { fs } from \"@warlock.js/fs\";\nimport { tokenizeCommand } from \"../policy/tokenize-command\";\nimport type { WorkspaceShellInterpreter } from \"../contracts/workspace-policy.type\";\nimport type {\n WorkspaceBackend,\n WorkspaceBackendExecOptions,\n WorkspaceBackendExecResult,\n WorkspaceBackendProcess,\n WorkspaceBackendStartOptions,\n WorkspaceBackendDirEntry,\n WorkspaceBackendStat,\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(argv: string[]): { 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/** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */\nconst POSIX_SHELL = \"/bin/sh\";\n\n/** How to spawn a command line, or why it cannot be spawned. */\nexport type SpawnPlan =\n | { file: string; args: string[]; windowsVerbatimArguments: boolean }\n | { refused: string };\n\n/**\n * Work out the spawn for a command line.\n *\n * - argv mode: tokenize (no shell semantics) and spawn the argv directly —\n * on Windows through an individually-quoted `cmd.exe` wrapper.\n * - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c \"<line>\"`\n * on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,\n * `/s` keeps the line verbatim inside the outer quotes). The ops layer has\n * already checked every command in the line.\n */\nexport function planSpawn(\n command: string,\n interpreter: WorkspaceShellInterpreter,\n interpreterPath?: string,\n hostPlatform: NodeJS.Platform = platform,\n): SpawnPlan {\n if (interpreter === \"powershell\") {\n return {\n file: interpreterPath ?? (hostPlatform === \"win32\" ? \"powershell.exe\" : \"pwsh\"),\n args: [\"-NoLogo\", \"-NoProfile\", \"-NonInteractive\", \"-Command\", command],\n windowsVerbatimArguments: false,\n };\n }\n\n if (interpreter === \"bash\") {\n return {\n file: interpreterPath ?? \"bash\",\n args: [\"-c\", command],\n windowsVerbatimArguments: false,\n };\n }\n\n if (interpreter === \"cmd\" || (interpreter === \"sh\" && hostPlatform === \"win32\")) {\n return {\n file: interpreterPath ?? process.env.ComSpec ?? \"cmd.exe\",\n args: [\"/d\", \"/v:off\", \"/s\", \"/c\", `\"${command}\"`],\n windowsVerbatimArguments: true,\n };\n }\n\n if (interpreter === \"sh\") {\n return {\n file: interpreterPath ?? POSIX_SHELL,\n args: [\"-c\", command],\n windowsVerbatimArguments: false,\n };\n }\n\n const argv = tokenizeCommand(command);\n const executable = argv?.[0];\n\n if (argv === null || executable === undefined) {\n return {\n refused:\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\n if (platform === \"win32\") {\n const invocation = toWin32CmdInvocation(argv);\n\n if (invocation === null) {\n return {\n refused:\n 'Command was not executed: on Windows, arguments containing \", %, ' +\n \"or newlines cannot be passed to cmd.exe safely.\",\n };\n }\n\n return { ...invocation, windowsVerbatimArguments: true };\n }\n\n return { file: executable, args: argv.slice(1), windowsVerbatimArguments: false };\n}\n\n/**\n * Kill functions of every background process still running, so they can\n * be reaped if the Node process exits without the workspace being disposed\n * (detached process groups would otherwise outlive it).\n */\nconst liveBackgroundKills = new Set<() => void>();\n\nlet exitHookInstalled = false;\n\n/** Track a live background tree; installs (once) the exit hook that kills them. */\nfunction trackBackgroundProcess(kill: () => void): () => void {\n liveBackgroundKills.add(kill);\n\n if (!exitHookInstalled) {\n exitHookInstalled = true;\n process.on(\"exit\", () => {\n for (const killTreeNow of liveBackgroundKills) {\n killTreeNow();\n }\n });\n }\n\n return () => {\n liveBackgroundKills.delete(kill);\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 /** List children with their kinds from one directory read (symlinks not followed). */\n public async listTyped(absDir: string): Promise<WorkspaceBackendDirEntry[]> {\n const entries = await readdir(absDir, { withFileTypes: true });\n\n return entries.map((entry) => ({\n path: path.join(absDir, entry.name),\n type: entry.isSymbolicLink()\n ? \"symlink\"\n : entry.isDirectory()\n ? \"directory\"\n : entry.isFile()\n ? \"file\"\n : \"other\",\n }));\n }\n\n /**\n * Describe a path without following a final symlink (`lstat` — like\n * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).\n */\n public async stat(absPath: string): Promise<WorkspaceBackendStat> {\n const stats = await lstat(absPath);\n const type = stats.isSymbolicLink()\n ? \"symlink\"\n : stats.isDirectory()\n ? \"directory\"\n : stats.isFile()\n ? \"file\"\n : \"other\";\n\n return { type, size: stats.size };\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. In argv mode (the default) the\n * command line is tokenized into an argv (quotes respected, NO shell\n * semantics — see `tokenizeCommand`) and spawned **without a shell**, so\n * metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never\n * chain extra commands past the ops layer's allowlist; a command they\n * appear unquoted in is refused with exit code 127. On Windows the argv\n * runs 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 * With `interpreter: \"sh\"` the line runs through `/bin/sh -c` (or\n * `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has\n * checked every command in it.\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 plan = planSpawn(command, opts.interpreter ?? \"none\", opts.interpreterPath);\n\n if (\"refused\" in plan) {\n refuse(plan.refused);\n\n return;\n }\n\n const child = spawn(plan.file, plan.args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments: plan.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 for (const stream of [\"stdout\", \"stderr\"] as const) {\n const tail = liveDecoders[stream].end();\n\n if (tail !== \"\") {\n opts.onOutput?.(stream, tail);\n }\n }\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 const liveDecoders = {\n stdout: new StringDecoder(\"utf8\"),\n stderr: new StringDecoder(\"utf8\"),\n };\n\n const live = (stream: \"stdout\" | \"stderr\", chunk: Buffer): void => {\n if (opts.onOutput === undefined || settled) {\n return;\n }\n\n const text = liveDecoders[stream].write(chunk);\n\n if (text !== \"\") {\n opts.onOutput(stream, text);\n }\n };\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);\n live(\"stdout\", chunk);\n });\n\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);\n live(\"stderr\", 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 * Start a long-running command and return at once. Spawned like\n * {@link exec} (same argv/shell planning, own process group on POSIX), with\n * stdin closed. Output is decoded per stream with a `StringDecoder`, so a\n * chunk never ends inside a character. `kill()` kills the whole tree; a\n * still-running tree is also killed when the Node process exits.\n */\n public start(command: string, opts: WorkspaceBackendStartOptions): WorkspaceBackendProcess {\n const plan = planSpawn(command, opts.interpreter ?? \"none\", opts.interpreterPath);\n\n if (\"refused\" in plan) {\n opts.onOutput(\"stderr\", plan.refused);\n\n return { done: Promise.resolve({ exitCode: 127 }), kill: () => {} };\n }\n\n const child = spawn(plan.file, plan.args, {\n cwd: opts.cwd,\n env: opts.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments: plan.windowsVerbatimArguments,\n detached: platform !== \"win32\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n const stdoutDecoder = new StringDecoder(\"utf8\");\n const stderrDecoder = new StringDecoder(\"utf8\");\n let finished = false;\n\n const kill = (): void => {\n if (!finished) {\n killTree(child.pid, child);\n }\n };\n\n const untrack = trackBackgroundProcess(kill);\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n const text = stdoutDecoder.write(chunk);\n\n if (text !== \"\") {\n opts.onOutput(\"stdout\", text);\n }\n });\n\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n const text = stderrDecoder.write(chunk);\n\n if (text !== \"\") {\n opts.onOutput(\"stderr\", text);\n }\n });\n\n const done = new Promise<{ exitCode: number }>((resolve) => {\n const finish = (exitCode: number): void => {\n if (finished) {\n return;\n }\n\n finished = true;\n untrack();\n\n const stdoutTail = stdoutDecoder.end();\n const stderrTail = stderrDecoder.end();\n\n if (stdoutTail !== \"\") {\n opts.onOutput(\"stdout\", stdoutTail);\n }\n\n if (stderrTail !== \"\") {\n opts.onOutput(\"stderr\", stderrTail);\n }\n\n resolve({ exitCode });\n };\n\n child.on(\"error\", (error) => {\n opts.onOutput(\"stderr\", `Command could not be started: ${error.message}`);\n finish(127);\n });\n\n child.on(\"close\", (code, signal) => {\n finish(code === null ? 128 + (signal === \"SIGKILL\" ? 9 : 1) : code);\n });\n });\n\n return { ...(child.pid !== undefined ? { pid: child.pid } : {}), done, kill };\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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"}
@@ -2,9 +2,9 @@ import { WorkspaceEditMatchTier, WorkspaceEditMatching } from "../edit/match.mjs
2
2
  import { WorkspaceOutputKeep, WorkspaceOutputPolicy, WorkspaceTruncation } from "./workspace-output.type.mjs";
3
3
  import { ApplyPatchFileResult, ApplyPatchInput, ApplyPatchResult, EditFileInput, EditFileResult, ExecOptions, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ListDirEntry, ListDirEntryType, ListDirInput, ListDirOptions, ListDirResult, MultiEditInput, MultiEditOperation, MultiEditResult, ReadFileInput, ReadFileResult, RunShellBackgroundResult, RunShellInput, RunShellResult, RunTestsInput, ShellJobInfo, ShellJobStatus, ShellKillInput, ShellOutputInput, ShellOutputOptions, ShellOutputResult, StartShellOptions, TodoListResult, TodoWriteInput, WorkspaceTodo, WorkspaceTodoInput, WorkspaceTodoStatus, WorkspaceToolName, WriteFileInput, WriteFileResult } from "./tool-io.type.mjs";
4
4
  import { WorkspaceCommandDecision, WorkspaceCommandRule, WorkspaceShellAsk, WorkspaceShellAskDecision, WorkspaceShellAskReason, WorkspaceShellAskRequest, WorkspaceShellClassification, WorkspaceShellOnAsk } from "./shell-approval.type.mjs";
5
- import { WorkspaceAfterEditEvent, WorkspaceEditLineEndings, WorkspaceEditPolicy, WorkspaceEditTool, WorkspaceHooks } from "./workspace-edit.type.mjs";
6
- import { WorkspaceAllowCommand, WorkspaceBackendType, WorkspaceBackgroundPolicy, WorkspaceCommandCheckContext, WorkspaceCommandPattern, WorkspaceListPolicy, WorkspacePolicy, WorkspaceReadPolicy, WorkspaceShellInterpreter, WorkspaceShellPolicy } from "./workspace-policy.type.mjs";
7
- import { WorkspaceBackend, WorkspaceBackendExecOptions, WorkspaceBackendExecResult, WorkspaceBackendProcess, WorkspaceBackendStartOptions, WorkspaceBackendStat, WorkspaceOutputStream } from "./workspace-backend.contract.mjs";
5
+ import { WorkspaceAfterEditEvent, WorkspaceEditLineEndings, WorkspaceEditPolicy, WorkspaceEditTool, WorkspaceHooks, WorkspaceShellOutputEvent } from "./workspace-edit.type.mjs";
6
+ import { WorkspaceAllowCommand, WorkspaceBackendType, WorkspaceBackgroundPolicy, WorkspaceCommandCheckContext, WorkspaceCommandPattern, WorkspaceListPolicy, WorkspacePolicy, WorkspaceReadPolicy, WorkspaceSearchPolicy, WorkspaceShellInterpreter, WorkspaceShellPolicy } from "./workspace-policy.type.mjs";
7
+ import { WorkspaceBackend, WorkspaceBackendDirEntry, WorkspaceBackendExecOptions, WorkspaceBackendExecResult, WorkspaceBackendProcess, WorkspaceBackendStartOptions, WorkspaceBackendStat, WorkspaceOutputStream } from "./workspace-backend.contract.mjs";
8
8
  import { WorkspaceOps } from "./workspace-ops.contract.mjs";
9
9
  import { Workspace, WorkspaceTools } from "./workspace.contract.mjs";
10
- export type { ApplyPatchFileResult, ApplyPatchInput, ApplyPatchResult, EditFileInput, EditFileResult, ExecOptions, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ListDirEntry, ListDirEntryType, ListDirInput, ListDirOptions, ListDirResult, MultiEditInput, MultiEditOperation, MultiEditResult, ReadFileInput, ReadFileResult, RunShellBackgroundResult, RunShellInput, RunShellResult, RunTestsInput, ShellJobInfo, ShellJobStatus, ShellKillInput, ShellOutputInput, ShellOutputOptions, ShellOutputResult, StartShellOptions, TodoListResult, TodoWriteInput, Workspace, WorkspaceAfterEditEvent, WorkspaceAllowCommand, WorkspaceBackend, WorkspaceBackendExecOptions, WorkspaceBackendExecResult, WorkspaceBackendProcess, WorkspaceBackendStartOptions, WorkspaceBackendStat, WorkspaceBackendType, WorkspaceBackgroundPolicy, WorkspaceCommandCheckContext, WorkspaceCommandDecision, WorkspaceCommandPattern, WorkspaceCommandRule, WorkspaceEditLineEndings, WorkspaceEditMatchTier, WorkspaceEditMatching, WorkspaceEditPolicy, WorkspaceEditTool, WorkspaceHooks, WorkspaceListPolicy, WorkspaceOps, WorkspaceOutputKeep, WorkspaceOutputPolicy, WorkspaceOutputStream, WorkspacePolicy, WorkspaceReadPolicy, WorkspaceShellAsk, WorkspaceShellAskDecision, WorkspaceShellAskReason, WorkspaceShellAskRequest, WorkspaceShellClassification, WorkspaceShellInterpreter, WorkspaceShellOnAsk, WorkspaceShellPolicy, WorkspaceTodo, WorkspaceTodoInput, WorkspaceTodoStatus, WorkspaceToolName, WorkspaceTools, WorkspaceTruncation, WriteFileInput, WriteFileResult };
10
+ export type { ApplyPatchFileResult, ApplyPatchInput, ApplyPatchResult, EditFileInput, EditFileResult, ExecOptions, GlobInput, GlobResult, GrepInput, GrepMatch, GrepResult, ListDirEntry, ListDirEntryType, ListDirInput, ListDirOptions, ListDirResult, MultiEditInput, MultiEditOperation, MultiEditResult, ReadFileInput, ReadFileResult, RunShellBackgroundResult, RunShellInput, RunShellResult, RunTestsInput, ShellJobInfo, ShellJobStatus, ShellKillInput, ShellOutputInput, ShellOutputOptions, ShellOutputResult, StartShellOptions, TodoListResult, TodoWriteInput, Workspace, WorkspaceAfterEditEvent, WorkspaceAllowCommand, WorkspaceBackend, WorkspaceBackendDirEntry, WorkspaceBackendExecOptions, WorkspaceBackendExecResult, WorkspaceBackendProcess, WorkspaceBackendStartOptions, WorkspaceBackendStat, WorkspaceBackendType, WorkspaceBackgroundPolicy, WorkspaceCommandCheckContext, WorkspaceCommandDecision, WorkspaceCommandPattern, WorkspaceCommandRule, WorkspaceEditLineEndings, WorkspaceEditMatchTier, WorkspaceEditMatching, WorkspaceEditPolicy, WorkspaceEditTool, WorkspaceHooks, WorkspaceListPolicy, WorkspaceOps, WorkspaceOutputKeep, WorkspaceOutputPolicy, WorkspaceOutputStream, WorkspacePolicy, WorkspaceReadPolicy, WorkspaceSearchPolicy, WorkspaceShellAsk, WorkspaceShellAskDecision, WorkspaceShellAskReason, WorkspaceShellAskRequest, WorkspaceShellClassification, WorkspaceShellInterpreter, WorkspaceShellOnAsk, WorkspaceShellOutputEvent, WorkspaceShellPolicy, WorkspaceTodo, WorkspaceTodoInput, WorkspaceTodoStatus, WorkspaceToolName, WorkspaceTools, WorkspaceTruncation, WriteFileInput, WriteFileResult };
@@ -195,6 +195,8 @@ export interface ExecOptions {
195
195
  * `cancelled: true`. The tools pass the agent run's signal here.
196
196
  */
197
197
  signal?: AbortSignal;
198
+ /** Echoed as `callId` on `hooks.onShellOutput` events for this command. */
199
+ callId?: string;
198
200
  }
199
201
  /** Input to the `run_shell` tool / `exec()` direct method. */
200
202
  export interface RunShellInput {
@@ -369,6 +371,8 @@ export interface GrepResult {
369
371
  truncation?: WorkspaceTruncation;
370
372
  /** Absolute path of the file holding the complete output, when it spilled to disk. */
371
373
  spilledTo?: string[];
374
+ /** True when the search stopped at `search.maxFiles` before it had looked at every file. */
375
+ maxFilesReached?: boolean;
372
376
  }
373
377
  /** Input to the `list_dir` tool. */
374
378
  export interface ListDirInput {
@@ -423,6 +427,8 @@ export interface GlobResult {
423
427
  truncation?: WorkspaceTruncation;
424
428
  /** Absolute path of the file holding the complete output, when it spilled to disk. */
425
429
  spilledTo?: string[];
430
+ /** True when the search stopped at `search.maxFiles` before it had looked at every file. */
431
+ maxFilesReached?: boolean;
426
432
  }
427
433
  /** State of one todo item. */
428
434
  export type WorkspaceTodoStatus = "pending" | "in_progress" | "completed";
@@ -1 +1 @@
1
- {"version":3,"file":"tool-io.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/tool-io.type.ts"],"mappings":";;;;;;;;;;;;;;YAeY;;iBAiBK;;EAEf;;EAEA;;EAEA;;;;;;;;;;;;;;iBAee;;EAEf;;EAEA;;;;;EAKA;;EAEA;;;;;;EAMA;;EAEA;;EAEA,aAAa;;EAEb;;EAEA;;;;;;EAMA;;;;;;iBAOe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA,YAAY,QAAQ;;EAEpB;;;iBAIe;;EAEf;;EAEA;;EAEA;;;;;;;;iBASe;;EAEf;;EAEA,OAAO;;EAEP;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA,YAAY,QAAQ;;EAEpB;;;;;;;iBAQe;;EAEf;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;iBAIe;;EAEf,OAAO;;EAEP;;EAEA;;;iBAIe;;EAEf;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;;iBAIe;;EAEf;;;;;;EAMA,SAAS;;;iBAIM;;EAEf;;EAEA;;;;;EAKA;;;;;;;;;;;YAYU;;iBAGK;;EAEf;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;;EAEA;;;iBAIe,iCAAiC;;EAEhD;;;iBAIe;;;;;;EAMf;;;iBAIe;;EAEf;;;;;EAKA;;;;;EAKA;;;iBAIe;;EAEf;;EAEA;;EAEA,SAAS;;;iBAIM,0BAA0B;;EAEzC;;EAEA;;;;;EAKA;;;;;EAKA;;EAEA;;EAEA;IAAe,SAAS;IAAqB,SAAS;;;EAEtD;;;iBAIe;;EAEf;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;IAAe,SAAS;IAAqB,SAAS;;;EAEtD;;;;;;EAMA;;;;;;;iBAQe;;EAEf;;;iBAIe;;EAEf;;EAEA;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;;iBAIe;;EAEf,SAAS;;EAET;;EAEA;;EAEA,aAAa;;EAEb;;;iBAIe;;EAEf;;EAEA;;EAEA;;;iBAIe;;EAEf;;EAEA;;;YAIU;;iBAGK;;EAEf;;EAEA,MAAM;;EAEN;;EAEA;;;iBAIe;;EAEf;;EAEA,SAAS;;EAET;;;iBAIe;;EAEf;;;iBAIe;;EAEf;;EAEA;;EAEA,aAAa;;EAEb;;;YAIU;;iBAGK;;EAEf;;EAEA;;EAEA,QAAQ;;;iBAIO;;EAEf;;EAEA;;EAEA,QAAQ;;;iBAIO;;EAEf,OAAO;;;iBAIQ;;EAEf,OAAO;;EAEP,QAAQ,OAAO"}
1
+ {"version":3,"file":"tool-io.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/tool-io.type.ts"],"mappings":";;;;;;;;;;;;;;YAeY;;iBAiBK;;EAEf;;EAEA;;EAEA;;;;;;;;;;;;;;iBAee;;EAEf;;EAEA;;;;;EAKA;;EAEA;;;;;;EAMA;;EAEA;;EAEA,aAAa;;EAEb;;EAEA;;;;;;EAMA;;;;;;iBAOe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA,YAAY,QAAQ;;EAEpB;;;iBAIe;;EAEf;;EAEA;;EAEA;;;;;;;;iBASe;;EAEf;;EAEA,OAAO;;EAEP;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA,YAAY,QAAQ;;EAEpB;;;;;;;iBAQe;;EAEf;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;iBAIe;;EAEf,OAAO;;EAEP;;EAEA;;;iBAIe;;EAEf;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;;iBAIe;;EAEf;;;;;;EAMA,SAAS;;EAET;;;iBAIe;;EAEf;;EAEA;;;;;EAKA;;;;;;;;;;;YAYU;;iBAGK;;EAEf;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;;EAEA;;;iBAIe,iCAAiC;;EAEhD;;;iBAIe;;;;;;EAMf;;;iBAIe;;EAEf;;;;;EAKA;;;;;EAKA;;;iBAIe;;EAEf;;EAEA;;EAEA,SAAS;;;iBAIM,0BAA0B;;EAEzC;;EAEA;;;;;EAKA;;;;;EAKA;;EAEA;;EAEA;IAAe,SAAS;IAAqB,SAAS;;;EAEtD;;;iBAIe;;EAEf;;;iBAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;IAAe,SAAS;IAAqB,SAAS;;;EAEtD;;;;;;EAMA;;;;;;;iBAQe;;EAEf;;;iBAIe;;EAEf;;EAEA;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;;iBAIe;;EAEf,SAAS;;EAET;;EAEA;;EAEA,aAAa;;EAEb;;EAEA;;;iBAIe;;EAEf;;EAEA;;EAEA;;;iBAIe;;EAEf;;EAEA;;;YAIU;;iBAGK;;EAEf;;EAEA,MAAM;;EAEN;;EAEA;;;iBAIe;;EAEf;;EAEA,SAAS;;EAET;;;iBAIe;;EAEf;;;iBAIe;;EAEf;;EAEA;;EAEA,aAAa;;EAEb;;EAEA;;;YAIU;;iBAGK;;EAEf;;EAEA;;EAEA,QAAQ;;;iBAIO;;EAEf;;EAEA;;EAEA,QAAQ;;;iBAIO;;EAEf,OAAO;;;iBAIQ;;EAEf,OAAO;;EAEP,QAAQ,OAAO"}
@@ -61,6 +61,13 @@ export interface WorkspaceBackendExecOptions {
61
61
  * host's `PATH`); absent ⇒ the backend uses the shell's usual name.
62
62
  */
63
63
  interpreterPath?: string;
64
+ /**
65
+ * Optional live output: called with decoded text (never split inside a
66
+ * character) while the command runs, never after `exec` settles. The
67
+ * resolved result still carries the whole capture. A backend that cannot
68
+ * stream may ignore it.
69
+ */
70
+ onOutput?(stream: WorkspaceOutputStream, chunk: string): void;
64
71
  }
65
72
  /** Which stream a background command's output chunk came from. */
66
73
  export type WorkspaceOutputStream = "stdout" | "stderr";
@@ -101,6 +108,13 @@ export interface WorkspaceBackendStat {
101
108
  /** Size in bytes. */
102
109
  size: number;
103
110
  }
111
+ /** One child of a directory, as {@link WorkspaceBackend.listTyped} reports it. */
112
+ export interface WorkspaceBackendDirEntry {
113
+ /** Absolute path of the child. */
114
+ path: string;
115
+ /** Entry kind; a symlink is reported as `"symlink"`, never followed. */
116
+ type: WorkspaceBackendStat["type"];
117
+ }
104
118
  /**
105
119
  * The swappable executor a workspace runs on — the thin, **policy-agnostic**
106
120
  * IO seam the ops layer calls. A backend takes **already-resolved absolute
@@ -132,6 +146,12 @@ export interface WorkspaceBackend {
132
146
  remove(absPath: string): Promise<void>;
133
147
  /** List immediate children of an absolute directory as absolute paths. */
134
148
  list(absDir: string): Promise<string[]>;
149
+ /**
150
+ * List the children of an absolute directory with their kinds, in one
151
+ * call. Optional: `glob` and `grep` fall back to `list` plus `stat`
152
+ * (or, without `stat`, a second `list` per entry).
153
+ */
154
+ listTyped?(absDir: string): Promise<WorkspaceBackendDirEntry[]>;
135
155
  /** Resolve symlinks/`..` to a canonical absolute path (jail enforcement). */
136
156
  realpath(absPath: string): Promise<string>;
137
157
  /** Run a command and capture its outcome. */
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-backend.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-backend.contract.ts"],"mappings":";;;;;;;;iBAQiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;EAOA;;;;;;;iBAQe;;EAEf;;EAEA;;EAEA,MAAM;;;;;;;;;EASN,SAAS;;;;;;;;;;;EAWT,cAAc;;;;;;EAMd;;;YAIU;;iBAGK;;EAEf;;EAEA,MAAM;;EAEN,cAAc;;EAEd;;;;;EAKA,SAAS,QAAQ,uBAAuB;;;iBAIzB;;WAEN;;;;;WAKA,MAAM;IAAU;;;EAEzB;;;iBAIe;;EAEf;;EAEA;;;;;;;;;;;;;;;;;;;;iBAqBe;;EAEf,SAAS,kBAAkB;;EAE3B,UAAU,iBAAiB,kBAAkB;;EAE7C,OAAO,kBAAkB;;EAEzB,MAAM,kBAAkB;;EAExB,OAAO,kBAAkB;;EAEzB,KAAK,iBAAiB;;EAEtB,SAAS,kBAAkB;;EAE3B,KAAK,iBAAiB,OAAO,8BAA8B,QAAQ;;;;;;EAMnE,MAAM,kBAAkB,QAAQ;;;;;;EAMhC,OAAO,iBAAiB,MAAM,+BAA+B"}
1
+ {"version":3,"file":"workspace-backend.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-backend.contract.ts"],"mappings":";;;;;;;;iBAQiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;EAOA;;;;;;;iBAQe;;EAEf;;EAEA;;EAEA,MAAM;;;;;;;;;EASN,SAAS;;;;;;;;;;;EAWT,cAAc;;;;;;EAMd;;;;;;;EAOA,UAAU,QAAQ,uBAAuB;;;YAI/B;;iBAGK;;EAEf;;EAEA,MAAM;;EAEN,cAAc;;EAEd;;;;;EAKA,SAAS,QAAQ,uBAAuB;;;iBAIzB;;WAEN;;;;;WAKA,MAAM;IAAU;;;EAEzB;;;iBAIe;;EAEf;;EAEA;;;iBAIe;;EAEf;;EAEA,MAAM;;;;;;;;;;;;;;;;;;;;iBAqBS;;EAEf,SAAS,kBAAkB;;EAE3B,UAAU,iBAAiB,kBAAkB;;EAE7C,OAAO,kBAAkB;;EAEzB,MAAM,kBAAkB;;EAExB,OAAO,kBAAkB;;EAEzB,KAAK,iBAAiB;;;;;;EAMtB,WAAW,iBAAiB,QAAQ;;EAEpC,SAAS,kBAAkB;;EAE3B,KAAK,iBAAiB,OAAO,8BAA8B,QAAQ;;;;;;EAMnE,MAAM,kBAAkB,QAAQ;;;;;;EAMhC,OAAO,iBAAiB,MAAM,+BAA+B"}
@@ -37,6 +37,19 @@ export interface WorkspaceAfterEditEvent {
37
37
  /** Cancellation of the agent run, when the caller has one. */
38
38
  signal?: AbortSignal;
39
39
  }
40
+ /** One piece of live `run_shell` output, as reported to {@link WorkspaceHooks.onShellOutput}. */
41
+ export interface WorkspaceShellOutputEvent {
42
+ /** The caller's id for a foreground call, when `exec` was given one. */
43
+ callId?: string;
44
+ /** The background job the output belongs to; absent for a foreground command. */
45
+ jobId?: string;
46
+ /** The command line that produced the output. */
47
+ command: string;
48
+ /** Which stream the text came from. */
49
+ stream: "stdout" | "stderr";
50
+ /** Decoded text, made of whole lines (the last piece of a stream may end without a newline). */
51
+ chunk: string;
52
+ }
40
53
  /**
41
54
  * Host callbacks around workspace changes.
42
55
  *
@@ -60,6 +73,23 @@ export interface WorkspaceHooks {
60
73
  afterEditMaxChars?: number;
61
74
  /** How long to wait for `afterEdit`, in ms. Default `10000`. */
62
75
  afterEditTimeoutMs?: number;
76
+ /**
77
+ * Live output of `run_shell`, foreground and background, for hosts that
78
+ * show a session to a person. Called with decoded text in order per
79
+ * stream, at most about 20 times a second (faster output is merged), and
80
+ * only with whole lines until a command ends. The result the agent gets
81
+ * (truncation, spilling) is unchanged, and a throw from the hook is
82
+ * ignored. The workspace redacts nothing in results, so the text is raw
83
+ * unless `redactShellOutput` is set.
84
+ */
85
+ onShellOutput?: (event: WorkspaceShellOutputEvent) => void;
86
+ /**
87
+ * Masks secrets in text before it reaches `onShellOutput`. It always sees
88
+ * a complete line (never half of one), so a secret split across two reads
89
+ * of the process is still found. Values in `shell.env` of eight or more
90
+ * characters are masked as `[redacted]` before this runs.
91
+ */
92
+ redactShellOutput?: (text: string) => string;
63
93
  }
64
94
  //#endregion
65
95
  export type { WorkspaceEditMatchTier, WorkspaceEditMatching };
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-edit.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-edit.type.ts"],"mappings":";;;YAKY;;;;iBAKK;;;;;;;;EAQf,WAAW;;;;;;EAMX;;;;;EAKA,cAAc;;;YAIJ;;iBAGK;;EAEf;;EAEA,MAAM;;EAEN,SAAS;;;;;;;;;;iBAWM;;;;;;;;;;EAUf,aAAa,OAAO,4BAA4B;;EAEhD;;EAEA"}
1
+ {"version":3,"file":"workspace-edit.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-edit.type.ts"],"mappings":";;;YAKY;;;;iBAKK;;;;;;;;EAQf,WAAW;;;;;;EAMX;;;;;EAKA,cAAc;;;YAIJ;;iBAGK;;EAEf;;EAEA,MAAM;;EAEN,SAAS;;;iBAIM;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;iBAWe;;;;;;;;;;EAUf,aAAa,OAAO,4BAA4B;;EAEhD;;EAEA;;;;;;;;;;EAUA,iBAAiB,OAAO;;;;;;;EAOxB,qBAAqB"}
@@ -85,14 +85,19 @@ export interface WorkspaceOps {
85
85
  grep(pattern: string, opts?: {
86
86
  glob?: string;
87
87
  ignoreCase?: boolean;
88
+ signal?: AbortSignal;
88
89
  }): Promise<GrepResult>;
89
90
  /** Resolve a glob to matching workspace-relative paths within the jail. */
90
- glob(pattern: string): Promise<string[]>;
91
+ glob(pattern: string, opts?: {
92
+ signal?: AbortSignal;
93
+ }): Promise<string[]>;
91
94
  /**
92
95
  * Like {@link WorkspaceOps.glob}, but returns the paths with the `output`
93
96
  * truncation metadata. Optional: the `glob` tool falls back to `glob()`.
94
97
  */
95
- globResult?(pattern: string): Promise<GlobResult>;
98
+ globResult?(pattern: string, opts?: {
99
+ signal?: AbortSignal;
100
+ }): Promise<GlobResult>;
96
101
  /** Whether a jailed path exists (file or directory). */
97
102
  exists(path: string): Promise<boolean>;
98
103
  /** Create a directory (and parents) at a jailed path; idempotent. */
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-ops.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-ops.contract.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAuCiB;;;;;;;EAOf,SAAS,cAAc;IAAS;IAAiB;MAAmB,QAAQ;;EAG5E,UACE,cACA,kBACC;IAAU;IAAc;IAAsB;;;;;;;EAOjD,SAAS,OAAO,gBAAgB,QAAQ;;;;;;;EAQxC,UAAU,OAAO,iBAAiB,QAAQ;;;;;;;;;EAU1C,YAAY,OAAO,kBAAkB,QAAQ;;;;;;;EAQ7C,KAAK,iBAAiB,OAAO,cAAc,QAAQ;;;;;EAMnD,WAAW,iBAAiB,OAAO,oBAAoB,QAAQ;;EAG/D,YAAY,YAAY,OAAO,qBAAqB,QAAQ;;EAG5D,UAAU,aAAa,QAAQ;;EAG/B,cAAc;;;;;EAMd,QAAQ,eAAe,OAAO,iBAAiB,QAAQ;;EAGvD,KAAK,iBAAiB;IAAS;IAAe;MAAyB,QAAQ;;EAG/E,KAAK,kBAAkB;;;;;EAMvB,YAAY,kBAAkB,QAAQ;;EAGtC,OAAO,eAAe;;EAGtB,MAAM,eAAe;;EAGrB,OAAO,eAAe;;;;;;EAOtB,WAAW"}
1
+ {"version":3,"file":"workspace-ops.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-ops.contract.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAuCiB;;;;;;;EAOf,SAAS,cAAc;IAAS;IAAiB;MAAmB,QAAQ;;EAG5E,UACE,cACA,kBACC;IAAU;IAAc;IAAsB;;;;;;;EAOjD,SAAS,OAAO,gBAAgB,QAAQ;;;;;;;EAQxC,UAAU,OAAO,iBAAiB,QAAQ;;;;;;;;;EAU1C,YAAY,OAAO,kBAAkB,QAAQ;;;;;;;EAQ7C,KAAK,iBAAiB,OAAO,cAAc,QAAQ;;;;;EAMnD,WAAW,iBAAiB,OAAO,oBAAoB,QAAQ;;EAG/D,YAAY,YAAY,OAAO,qBAAqB,QAAQ;;EAG5D,UAAU,aAAa,QAAQ;;EAG/B,cAAc;;;;;EAMd,QAAQ,eAAe,OAAO,iBAAiB,QAAQ;;EAGvD,KACE,iBACA;IAAS;IAAe;IAAsB,SAAS;MACtD,QAAQ;;EAGX,KAAK,iBAAiB;IAAS,SAAS;MAAgB;;;;;EAMxD,YAAY,iBAAiB;IAAS,SAAS;MAAgB,QAAQ;;EAGvE,OAAO,eAAe;;EAGtB,MAAM,eAAe;;EAGrB,OAAO,eAAe;;;;;;EAOtB,WAAW"}
@@ -210,6 +210,27 @@ export interface WorkspaceListPolicy {
210
210
  /** Most entries one listing returns; the rest set `truncated`. Default `500`. */
211
211
  maxEntries?: number;
212
212
  }
213
+ /**
214
+ * Search sub-policy for `glob` and `grep`. Both start at the pattern's
215
+ * static folder prefix and never read a denied, skipped or gitignored
216
+ * directory. The jail and `denyPaths` apply regardless of these settings.
217
+ */
218
+ export interface WorkspaceSearchPolicy {
219
+ /**
220
+ * Directory names or globs (`*`, `**`, `?`; a name without a slash matches
221
+ * at any depth) that are never descended into. Default `[".git",
222
+ * "node_modules"]`; setting it replaces the default. A pattern whose
223
+ * static prefix names a skipped folder (`node_modules/pkg/*.json`) still
224
+ * searches it.
225
+ */
226
+ skipDirs?: string[];
227
+ /** Honour `.gitignore` files met on the way down. Default `true`. */
228
+ gitignore?: boolean;
229
+ /** Most files one search looks at before it stops and reports `truncated`. Default `50000`. */
230
+ maxFiles?: number;
231
+ /** Directories read at once. Default `16`. */
232
+ concurrency?: number;
233
+ }
213
234
  /**
214
235
  * The full policy that bounds a workspace — the single source of truth
215
236
  * the policy engine enforces for both `.tools.*` and the direct methods.
@@ -242,6 +263,8 @@ export interface WorkspacePolicy {
242
263
  read?: WorkspaceReadPolicy;
243
264
  /** Directory-listing sub-policy (ignore globs + bounds). */
244
265
  list?: WorkspaceListPolicy;
266
+ /** Search sub-policy for `glob` and `grep` (skipped folders, `.gitignore`, bounds). */
267
+ search?: WorkspaceSearchPolicy;
245
268
  /** Edit sub-policy (matching tiers, miss hints, line endings). */
246
269
  edit?: WorkspaceEditPolicy;
247
270
  /** Host callbacks, such as `afterEdit` diagnostics. */
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":";;;;;;;;;;;;YAYY;;;;;;;;;;;;;;;;;;;;;;;YAwBA;;;;;;;;;;;;;YAcA,mCAAmC;;iBAG9B;;EAEf;;EAEA,aAAa;;;;;;EAMb;;;;;;;;;;;;;YAcU,yBACV,yBACA,SAAS;;;;;;;;iBAUM;;EAEf;;;;;;EAMA;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2Be;;EAEf;;EAEA;;;;;EAKA,eAAe;;;;;EAKf,eAAe;;;;;;;EAOf,cAAc;;;;;;EAMd;;;;;;;;EAQA,QAAQ;;;;;;;EAOR,QAAQ;;EAER;;;;;;EAMA;;EAEA,MAAM;;;;;EAKN;;;;;EAKA,aAAa;;;;;;iBAOE;;;;;;;;EAQf;;EAEA;;;;;;;iBAQe;;;;;;EAMf;;EAEA;;EAEA;;;;;;;;;;;;;;;;;;;;;iBAsBe;;EAEf;;EAEA;;EAEA;;EAEA,QAAQ;;EAER,OAAO;;EAEP,OAAO;;EAEP,OAAO;;EAEP,QAAQ;;EAER,SAAS;;EAET,UAAU"}
1
+ {"version":3,"file":"workspace-policy.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace-policy.type.ts"],"mappings":";;;;;;;;;;;;YAYY;;;;;;;;;;;;;;;;;;;;;;;YAwBA;;;;;;;;;;;;;YAcA,mCAAmC;;iBAG9B;;EAEf;;EAEA,aAAa;;;;;;EAMb;;;;;;;;;;;;;YAcU,yBACV,yBACA,SAAS;;;;;;;;iBAUM;;EAEf;;;;;;EAMA;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2Be;;EAEf;;EAEA;;;;;EAKA,eAAe;;;;;EAKf,eAAe;;;;;;;EAOf,cAAc;;;;;;EAMd;;;;;;;;EAQA,QAAQ;;;;;;;EAOR,QAAQ;;EAER;;;;;;EAMA;;EAEA,MAAM;;;;;EAKN;;;;;EAKA,aAAa;;;;;;iBAOE;;;;;;;;EAQf;;EAEA;;;;;;;iBAQe;;;;;;EAMf;;EAEA;;EAEA;;;;;;;iBAQe;;;;;;;;EAQf;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;;;;;;;iBAsBe;;EAEf;;EAEA;;EAEA;;EAEA,QAAQ;;EAER,OAAO;;EAEP,OAAO;;EAEP,SAAS;;EAET,OAAO;;EAEP,QAAQ;;EAER,SAAS;;EAET,UAAU"}
@@ -184,9 +184,12 @@ export interface Workspace {
184
184
  grep(pattern: string, opts?: {
185
185
  glob?: string;
186
186
  ignoreCase?: boolean;
187
+ signal?: AbortSignal;
187
188
  }): Promise<GrepResult>;
188
189
  /** Resolve a glob to matching workspace-relative paths within the jail. */
189
- glob(pattern: string): Promise<string[]>;
190
+ glob(pattern: string, opts?: {
191
+ signal?: AbortSignal;
192
+ }): Promise<string[]>;
190
193
  /** Whether a jailed path exists (file or directory). */
191
194
  exists(path: string): Promise<boolean>;
192
195
  /** Create a directory (and parents) at a jailed path; idempotent. */
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace.contract.ts"],"mappings":";;;;;;;;;;;;;;;iBAoCiB;;;;;;EAMf,OAAO;;EAEP,QAAQ,OAAO,sBAAsB;;EAErC,SAAS;IAAS;MAAkB;;EAEpC,SAAS;IAAS;MAAkB;;EAEpC,UAAU;IAAS;MAAkB;;EAErC,WAAW;IAAS;MAAkB;;EAEtC,UAAU;IAAS;MAAkB;;EAErC,QAAQ;IAAS;MAAkB;;EAEnC,SAAS;IAAS;MAAkB;;EAEpC,YAAY;IAAS;MAAkB;;EAEvC,UAAU;IAAS;MAAkB;;EAErC,SAAS;IAAS;IAAe;MAAqB;;EAEtD,KAAK;IAAS;MAAkB;;EAEhC,KAAK;IAAS;MAAkB;;EAEhC,UAAU;IAAS;MAAkB;;EAErC,SAAS;IAAS;MAAkB;;;;;;;;;;;;;;;;;;;;;;;;iBAyBrB;;WAEN,QAAQ;;WAGR,OAAO;;WAGP;;;;;;;IAOP,SAAS,eAAe;;;;;IAKxB,uBAAuB;;;;;;IAMvB,OAAO,SAAS;;;;;;;;EASlB,SAAS,cAAc;IAAS;IAAiB;MAAmB,QAAQ;;EAG5E,UAAU,cAAc,kBAAkB;IAAU;IAAc;;;EAGlE,SAAS,OAAO,gBAAgB,QAAQ;;EAGxC,UAAU,OAAO,iBAAiB,QAAQ;;EAG1C,WAAW,OAAO,kBAAkB,QAAQ;;EAG5C,QAAQ,eAAe,OAAO,iBAAiB,QAAQ;;;;;;EAOvD,KAAK,iBAAiB,OAAO,cAAc,QAAQ;;;;;;EAOnD,WAAW,iBAAiB,OAAO,oBAAoB,QAAQ;;EAG/D,YAAY,YAAY,OAAO,qBAAqB,QAAQ;;EAG5D,UAAU,aAAa,QAAQ;;EAG/B,cAAc;;EAGd,aAAa;;EAGb,WAAW,OAAO,uBAAuB;;;;;;;EAQzC,WAAW;;EAGX,KAAK,iBAAiB;IAAS;IAAe;MAAyB,QAAQ;;EAG/E,KAAK,kBAAkB;;EAGvB,OAAO,eAAe;;EAGtB,MAAM,eAAe;;EAGrB,OAAO,eAAe;;;;;;EAOtB,YAAY;;;;;;EAOZ,MAAM,iBAAiB"}
1
+ {"version":3,"file":"workspace.contract.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/contracts/workspace.contract.ts"],"mappings":";;;;;;;;;;;;;;;iBAoCiB;;;;;;EAMf,OAAO;;EAEP,QAAQ,OAAO,sBAAsB;;EAErC,SAAS;IAAS;MAAkB;;EAEpC,SAAS;IAAS;MAAkB;;EAEpC,UAAU;IAAS;MAAkB;;EAErC,WAAW;IAAS;MAAkB;;EAEtC,UAAU;IAAS;MAAkB;;EAErC,QAAQ;IAAS;MAAkB;;EAEnC,SAAS;IAAS;MAAkB;;EAEpC,YAAY;IAAS;MAAkB;;EAEvC,UAAU;IAAS;MAAkB;;EAErC,SAAS;IAAS;IAAe;MAAqB;;EAEtD,KAAK;IAAS;MAAkB;;EAEhC,KAAK;IAAS;MAAkB;;EAEhC,UAAU;IAAS;MAAkB;;EAErC,SAAS;IAAS;MAAkB;;;;;;;;;;;;;;;;;;;;;;;;iBAyBrB;;WAEN,QAAQ;;WAGR,OAAO;;WAGP;;;;;;;IAOP,SAAS,eAAe;;;;;IAKxB,uBAAuB;;;;;;IAMvB,OAAO,SAAS;;;;;;;;EASlB,SAAS,cAAc;IAAS;IAAiB;MAAmB,QAAQ;;EAG5E,UAAU,cAAc,kBAAkB;IAAU;IAAc;;;EAGlE,SAAS,OAAO,gBAAgB,QAAQ;;EAGxC,UAAU,OAAO,iBAAiB,QAAQ;;EAG1C,WAAW,OAAO,kBAAkB,QAAQ;;EAG5C,QAAQ,eAAe,OAAO,iBAAiB,QAAQ;;;;;;EAOvD,KAAK,iBAAiB,OAAO,cAAc,QAAQ;;;;;;EAOnD,WAAW,iBAAiB,OAAO,oBAAoB,QAAQ;;EAG/D,YAAY,YAAY,OAAO,qBAAqB,QAAQ;;EAG5D,UAAU,aAAa,QAAQ;;EAG/B,cAAc;;EAGd,aAAa;;EAGb,WAAW,OAAO,uBAAuB;;;;;;;EAQzC,WAAW;;EAGX,KACE,iBACA;IAAS;IAAe;IAAsB,SAAS;MACtD,QAAQ;;EAGX,KAAK,iBAAiB;IAAS,SAAS;MAAgB;;EAGxD,OAAO,eAAe;;EAGtB,MAAM,eAAe;;EAGrB,OAAO,eAAe;;;;;;EAOtB,YAAY;;;;;;EAOZ,MAAM,iBAAiB"}
@@ -0,0 +1,113 @@
1
+ //#region ../../packages/ai-workspace/src/hooks/shell-output.ts
2
+ /** Shortest gap between two deliveries to the hook (about 20 a second). */
3
+ const MIN_INTERVAL_MS = 50;
4
+ /** A line this long without a newline is delivered anyway (minus the tail kept back). */
5
+ const MAX_PENDING_CHARS = 16384;
6
+ /** Characters of an over-long line held back, so a secret on the cut is still whole next time. */
7
+ const HOLD_BACK_CHARS = 512;
8
+ /** Shortest `shell.env` value masked automatically. */
9
+ const MIN_SECRET_LENGTH = 8;
10
+ /**
11
+ * Feeds a command's output to `hooks.onShellOutput`.
12
+ *
13
+ * Redaction needs a whole secret in one piece, and a process writes in
14
+ * arbitrary pieces, so output is buffered to line boundaries before it is
15
+ * redacted and delivered; the unfinished last line waits until its newline
16
+ * arrives or the command ends (an over-long line is cut, keeping its last
17
+ * characters back). Deliveries are merged to at most one per
18
+ * {@link MIN_INTERVAL_MS} and the hook can never throw into the command.
19
+ */
20
+ var ShellOutputStreamer = class ShellOutputStreamer {
21
+ constructor(hooks, source, env) {
22
+ this.hooks = hooks;
23
+ this.source = source;
24
+ this.received = false;
25
+ this.pending = {
26
+ stdout: "",
27
+ stderr: ""
28
+ };
29
+ this.ready = {
30
+ stdout: "",
31
+ stderr: ""
32
+ };
33
+ this.lastEmit = 0;
34
+ this.ended = false;
35
+ this.secrets = Object.values(env ?? {}).filter((value) => value.length >= MIN_SECRET_LENGTH).sort((a, b) => b.length - a.length);
36
+ }
37
+ /** A streamer when the policy has `onShellOutput`, otherwise `undefined`. */
38
+ static create(hooks, source, env) {
39
+ return hooks?.onShellOutput === void 0 ? void 0 : new ShellOutputStreamer(hooks, source, env);
40
+ }
41
+ /** Add decoded output of one stream. */
42
+ push(stream, text) {
43
+ if (this.ended || text === "") return;
44
+ this.received = true;
45
+ let pending = this.pending[stream] + text;
46
+ const newline = pending.lastIndexOf("\n");
47
+ if (newline >= 0) {
48
+ this.ready[stream] += pending.slice(0, newline + 1);
49
+ pending = pending.slice(newline + 1);
50
+ }
51
+ if (pending.length > MAX_PENDING_CHARS) {
52
+ this.ready[stream] += pending.slice(0, pending.length - HOLD_BACK_CHARS);
53
+ pending = pending.slice(pending.length - HOLD_BACK_CHARS);
54
+ }
55
+ this.pending[stream] = pending;
56
+ this.schedule();
57
+ }
58
+ /** Deliver everything left, including unfinished lines, and stop. */
59
+ end() {
60
+ if (this.ended) return;
61
+ for (const stream of ["stdout", "stderr"]) {
62
+ this.ready[stream] += this.pending[stream];
63
+ this.pending[stream] = "";
64
+ }
65
+ this.clearTimer();
66
+ this.flush();
67
+ this.ended = true;
68
+ }
69
+ schedule() {
70
+ if (this.timer !== void 0) return;
71
+ const wait = Math.max(0, this.lastEmit + MIN_INTERVAL_MS - Date.now());
72
+ this.timer = setTimeout(() => {
73
+ this.timer = void 0;
74
+ this.flush();
75
+ }, wait);
76
+ this.timer.unref?.();
77
+ }
78
+ clearTimer() {
79
+ if (this.timer !== void 0) {
80
+ clearTimeout(this.timer);
81
+ this.timer = void 0;
82
+ }
83
+ }
84
+ flush() {
85
+ this.lastEmit = Date.now();
86
+ for (const stream of ["stdout", "stderr"]) {
87
+ const text = this.ready[stream];
88
+ if (text === "") continue;
89
+ this.ready[stream] = "";
90
+ this.deliver(stream, text);
91
+ }
92
+ }
93
+ deliver(stream, text) {
94
+ try {
95
+ let chunk = text;
96
+ for (const secret of this.secrets) chunk = chunk.split(secret).join("[redacted]");
97
+ chunk = this.hooks.redactShellOutput?.(chunk) ?? chunk;
98
+ if (chunk === "") return;
99
+ const event = {
100
+ command: this.source.command,
101
+ stream,
102
+ chunk,
103
+ ...this.source.callId !== void 0 ? { callId: this.source.callId } : {},
104
+ ...this.source.jobId !== void 0 ? { jobId: this.source.jobId } : {}
105
+ };
106
+ this.hooks.onShellOutput?.(event);
107
+ } catch {}
108
+ }
109
+ };
110
+
111
+ //#endregion
112
+ export { ShellOutputStreamer };
113
+ //# sourceMappingURL=shell-output.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell-output.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/hooks/shell-output.ts"],"sourcesContent":["import type {\n WorkspaceHooks,\n WorkspaceOutputStream,\n WorkspaceShellOutputEvent,\n} from \"../contracts\";\n\n/** Shortest gap between two deliveries to the hook (about 20 a second). */\nconst MIN_INTERVAL_MS = 50;\n/** A line this long without a newline is delivered anyway (minus the tail kept back). */\nconst MAX_PENDING_CHARS = 16_384;\n/** Characters of an over-long line held back, so a secret on the cut is still whole next time. */\nconst HOLD_BACK_CHARS = 512;\n/** Shortest `shell.env` value masked automatically. */\nconst MIN_SECRET_LENGTH = 8;\n\n/** What identifies the command whose output is streamed. */\nexport interface ShellOutputSource {\n command: string;\n callId?: string;\n jobId?: string;\n}\n\n/**\n * Feeds a command's output to `hooks.onShellOutput`.\n *\n * Redaction needs a whole secret in one piece, and a process writes in\n * arbitrary pieces, so output is buffered to line boundaries before it is\n * redacted and delivered; the unfinished last line waits until its newline\n * arrives or the command ends (an over-long line is cut, keeping its last\n * characters back). Deliveries are merged to at most one per\n * {@link MIN_INTERVAL_MS} and the hook can never throw into the command.\n */\nexport class ShellOutputStreamer {\n /** Whether any output was pushed. */\n public received = false;\n\n private readonly pending: Record<WorkspaceOutputStream, string> = { stdout: \"\", stderr: \"\" };\n\n private readonly ready: Record<WorkspaceOutputStream, string> = { stdout: \"\", stderr: \"\" };\n\n private lastEmit = 0;\n\n private timer?: ReturnType<typeof setTimeout>;\n\n private ended = false;\n\n private readonly secrets: string[];\n\n public constructor(\n private readonly hooks: WorkspaceHooks,\n private readonly source: ShellOutputSource,\n env: Record<string, string> | undefined,\n ) {\n this.secrets = Object.values(env ?? {})\n .filter((value) => value.length >= MIN_SECRET_LENGTH)\n .sort((a, b) => b.length - a.length);\n }\n\n /** A streamer when the policy has `onShellOutput`, otherwise `undefined`. */\n public static create(\n hooks: WorkspaceHooks | undefined,\n source: ShellOutputSource,\n env: Record<string, string> | undefined,\n ): ShellOutputStreamer | undefined {\n return hooks?.onShellOutput === undefined\n ? undefined\n : new ShellOutputStreamer(hooks, source, env);\n }\n\n /** Add decoded output of one stream. */\n public push(stream: WorkspaceOutputStream, text: string): void {\n if (this.ended || text === \"\") {\n return;\n }\n\n this.received = true;\n\n let pending = this.pending[stream] + text;\n const newline = pending.lastIndexOf(\"\\n\");\n\n if (newline >= 0) {\n this.ready[stream] += pending.slice(0, newline + 1);\n pending = pending.slice(newline + 1);\n }\n\n if (pending.length > MAX_PENDING_CHARS) {\n this.ready[stream] += pending.slice(0, pending.length - HOLD_BACK_CHARS);\n pending = pending.slice(pending.length - HOLD_BACK_CHARS);\n }\n\n this.pending[stream] = pending;\n this.schedule();\n }\n\n /** Deliver everything left, including unfinished lines, and stop. */\n public end(): void {\n if (this.ended) {\n return;\n }\n\n for (const stream of [\"stdout\", \"stderr\"] as const) {\n this.ready[stream] += this.pending[stream];\n this.pending[stream] = \"\";\n }\n\n this.clearTimer();\n this.flush();\n this.ended = true;\n }\n\n private schedule(): void {\n if (this.timer !== undefined) {\n return;\n }\n\n const wait = Math.max(0, this.lastEmit + MIN_INTERVAL_MS - Date.now());\n\n this.timer = setTimeout(() => {\n this.timer = undefined;\n this.flush();\n }, wait);\n this.timer.unref?.();\n }\n\n private clearTimer(): void {\n if (this.timer !== undefined) {\n clearTimeout(this.timer);\n this.timer = undefined;\n }\n }\n\n private flush(): void {\n this.lastEmit = Date.now();\n\n for (const stream of [\"stdout\", \"stderr\"] as const) {\n const text = this.ready[stream];\n\n if (text === \"\") {\n continue;\n }\n\n this.ready[stream] = \"\";\n this.deliver(stream, text);\n }\n }\n\n private deliver(stream: WorkspaceOutputStream, text: string): void {\n try {\n let chunk = text;\n\n for (const secret of this.secrets) {\n chunk = chunk.split(secret).join(\"[redacted]\");\n }\n\n chunk = this.hooks.redactShellOutput?.(chunk) ?? chunk;\n\n if (chunk === \"\") {\n return;\n }\n\n const event: WorkspaceShellOutputEvent = {\n command: this.source.command,\n stream,\n chunk,\n ...(this.source.callId !== undefined ? { callId: this.source.callId } : {}),\n ...(this.source.jobId !== undefined ? { jobId: this.source.jobId } : {}),\n };\n\n this.hooks.onShellOutput?.(event);\n } catch {\n // A failing hook or redactor never touches the command; the text is dropped, not leaked.\n }\n }\n}\n"],"mappings":";;AAOA,MAAM,kBAAkB;;AAExB,MAAM,oBAAoB;;AAE1B,MAAM,kBAAkB;;AAExB,MAAM,oBAAoB;;;;;;;;;;;AAmB1B,IAAa,sBAAb,MAAa,oBAAoB;CAgB/B,AAAO,YACL,AAAiB,OACjB,AAAiB,QACjB,KACA;EAHiB;EACA;kBAhBD;iBAEgD;GAAE,QAAQ;GAAI,QAAQ;EAAG;eAE3B;GAAE,QAAQ;GAAI,QAAQ;EAAG;kBAEtE;eAIH;EASd,KAAK,UAAU,OAAO,OAAO,OAAO,CAAC,CAAC,CAAC,CACpC,QAAQ,UAAU,MAAM,UAAU,iBAAiB,CAAC,CACpD,MAAM,GAAG,MAAM,EAAE,SAAS,EAAE,MAAM;CACvC;;CAGA,OAAc,OACZ,OACA,QACA,KACiC;EACjC,OAAO,OAAO,kBAAkB,SAC5B,SACA,IAAI,oBAAoB,OAAO,QAAQ,GAAG;CAChD;;CAGA,AAAO,KAAK,QAA+B,MAAoB;EAC7D,IAAI,KAAK,SAAS,SAAS,IACzB;EAGF,KAAK,WAAW;EAEhB,IAAI,UAAU,KAAK,QAAQ,UAAU;EACrC,MAAM,UAAU,QAAQ,YAAY,IAAI;EAExC,IAAI,WAAW,GAAG;GAChB,KAAK,MAAM,WAAW,QAAQ,MAAM,GAAG,UAAU,CAAC;GAClD,UAAU,QAAQ,MAAM,UAAU,CAAC;EACrC;EAEA,IAAI,QAAQ,SAAS,mBAAmB;GACtC,KAAK,MAAM,WAAW,QAAQ,MAAM,GAAG,QAAQ,SAAS,eAAe;GACvE,UAAU,QAAQ,MAAM,QAAQ,SAAS,eAAe;EAC1D;EAEA,KAAK,QAAQ,UAAU;EACvB,KAAK,SAAS;CAChB;;CAGA,AAAO,MAAY;EACjB,IAAI,KAAK,OACP;EAGF,KAAK,MAAM,UAAU,CAAC,UAAU,QAAQ,GAAY;GAClD,KAAK,MAAM,WAAW,KAAK,QAAQ;GACnC,KAAK,QAAQ,UAAU;EACzB;EAEA,KAAK,WAAW;EAChB,KAAK,MAAM;EACX,KAAK,QAAQ;CACf;CAEA,AAAQ,WAAiB;EACvB,IAAI,KAAK,UAAU,QACjB;EAGF,MAAM,OAAO,KAAK,IAAI,GAAG,KAAK,WAAW,kBAAkB,KAAK,IAAI,CAAC;EAErE,KAAK,QAAQ,iBAAiB;GAC5B,KAAK,QAAQ;GACb,KAAK,MAAM;EACb,GAAG,IAAI;EACP,KAAK,MAAM,QAAQ;CACrB;CAEA,AAAQ,aAAmB;EACzB,IAAI,KAAK,UAAU,QAAW;GAC5B,aAAa,KAAK,KAAK;GACvB,KAAK,QAAQ;EACf;CACF;CAEA,AAAQ,QAAc;EACpB,KAAK,WAAW,KAAK,IAAI;EAEzB,KAAK,MAAM,UAAU,CAAC,UAAU,QAAQ,GAAY;GAClD,MAAM,OAAO,KAAK,MAAM;GAExB,IAAI,SAAS,IACX;GAGF,KAAK,MAAM,UAAU;GACrB,KAAK,QAAQ,QAAQ,IAAI;EAC3B;CACF;CAEA,AAAQ,QAAQ,QAA+B,MAAoB;EACjE,IAAI;GACF,IAAI,QAAQ;GAEZ,KAAK,MAAM,UAAU,KAAK,SACxB,QAAQ,MAAM,MAAM,MAAM,CAAC,CAAC,KAAK,YAAY;GAG/C,QAAQ,KAAK,MAAM,oBAAoB,KAAK,KAAK;GAEjD,IAAI,UAAU,IACZ;GAGF,MAAM,QAAmC;IACvC,SAAS,KAAK,OAAO;IACrB;IACA;IACA,GAAI,KAAK,OAAO,WAAW,SAAY,EAAE,QAAQ,KAAK,OAAO,OAAO,IAAI,CAAC;IACzE,GAAI,KAAK,OAAO,UAAU,SAAY,EAAE,OAAO,KAAK,OAAO,MAAM,IAAI,CAAC;GACxE;GAEA,KAAK,MAAM,gBAAgB,KAAK;EAClC,QAAQ,CAER;CACF;AACF"}