@warlock.js/ai-workspace 5.29.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 +24 -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 +41 -5
- package/llms.txt +1 -1
- package/package.json +5 -4
- package/skills/use-a-workspace/SKILL.md +41 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to `@warlock.js/ai-workspace` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 5.30.0 - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Upgrading
|
|
10
|
+
|
|
11
|
+
- `RunShellResult.cancelled` is now a required field: a custom `WorkspaceOps`/`Workspace` implementation or a test double that builds a `RunShellResult` must set it (`false` unless the command was cancelled).
|
|
12
|
+
- `Workspace.readFile()` and `WorkspaceOps.readFile()` now return `ReadFileResult` (adds `startLine`, `endLine`, `truncated`, `bytesCapped`, `totalBytes`); custom implementations must return those fields.
|
|
13
|
+
- A read window that starts past the end of the file now returns empty `content` instead of one numbered empty line.
|
|
14
|
+
- `read.maxBytes` now actually caps the returned `content`; a read that used to return a whole long file under a small cap now comes back cut, with `bytesCapped: true`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `exec()` takes a per-call `signal` (`ExecOptions`); `run_shell` and `run_tests` pass the agent run's `ctx.signal`. An already-aborted signal never starts the command; an abort mid-run kills the process tree. Both resolve with the new `cancelled: true` on `RunShellResult`, never together with `timedOut` (WR-010).
|
|
19
|
+
- `WorkspaceBackendExecOptions.signal` and `WorkspaceBackendExecResult.cancelled?` for custom backends; the local backend removes its abort listener when the command settles, and the mock backend honours a pre-aborted signal.
|
|
20
|
+
- Read results carry `startLine`, `endLine`, `truncated`, `bytesCapped` and `totalBytes`; `readFile()` and the `read_file` tool now return the same `ReadFileResult` for the same arguments.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `read.maxBytes` is enforced: the returned `content` (line-number prefixes and newlines included) never exceeds it, so a single long line no longer bypasses the cap. Lines are cut on a character boundary, never mid-character; `hash` remains the SHA-256 of the whole file (WR-009).
|
|
25
|
+
- Command output clipped at `shell.maxOutputBytes` is cut on a character boundary instead of ending in a replacement character.
|
|
26
|
+
- A read window past the end of the file returns empty `content` instead of one numbered empty line.
|
|
27
|
+
- Writes, edits and removes of the same path are serialized, so two concurrent `edit_file` calls (the agent now dispatches a trip's tool calls concurrently) can no longer both pass the stale-hash guard and silently drop one edit; the second re-reads and is rejected as stale, or applies on top when it pinned no hash.
|
|
28
|
+
- Aborting a command after it has already exited (while its stdio is still draining) no longer reports it as `cancelled`; the tree is still reaped so the result settles.
|
|
29
|
+
- On Windows, a `taskkill` that cannot be started no longer crashes the process with an unhandled `'error'` event; the direct child is killed instead.
|
|
30
|
+
|
|
7
31
|
## 5.29.0 - 2026-10-01
|
|
8
32
|
|
|
9
33
|
### Changed
|
package/README.md
CHANGED
|
@@ -133,6 +133,12 @@ for (const file of await ws.glob("src/models/**/*.ts")) {
|
|
|
133
133
|
- **Read-before-edit guard** — `read_file` returns a SHA-256 `hash`; `edit_file`
|
|
134
134
|
requires an exact, unique `oldString` (or `replaceAll`) and rejects on an
|
|
135
135
|
`expectHash` mismatch.
|
|
136
|
+
- **Bounded reads** — `read.maxBytes` caps the UTF-8 bytes a read returns (line
|
|
137
|
+
numbers included), cutting on a character boundary and flagging `bytesCapped`;
|
|
138
|
+
the `hash` still covers the whole file.
|
|
139
|
+
- **Cancellable commands** — `exec(cmd, { signal })`, and the agent run's signal
|
|
140
|
+
in `run_shell` / `run_tests`, stop the command (process tree killed, or never
|
|
141
|
+
started when already aborted) and report `cancelled: true`.
|
|
136
142
|
|
|
137
143
|
**Deferred to later phases** (intentionally absent here): the transactional
|
|
138
144
|
lifecycle (`snapshot` / `restore` / `diff` / `changes`), `on()` hooks, `dispose`,
|
package/cjs/index.cjs
CHANGED
|
@@ -34,7 +34,7 @@ let _warlock_js_fs = require("@warlock.js/fs");
|
|
|
34
34
|
let node_child_process = require("node:child_process");
|
|
35
35
|
let node_process = require("node:process");
|
|
36
36
|
|
|
37
|
-
//#region
|
|
37
|
+
//#region ../../packages/ai-workspace/src/errors.ts
|
|
38
38
|
/**
|
|
39
39
|
* The workspace policy engine refused an operation — a path escaped the
|
|
40
40
|
* jail (or hit a deny glob), or a shell command's executable was not
|
|
@@ -89,7 +89,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
|
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
//#endregion
|
|
92
|
-
//#region
|
|
92
|
+
//#region ../../packages/ai-workspace/src/policy/tokenize-command.ts
|
|
93
93
|
/**
|
|
94
94
|
* Characters that are refused when they appear UNQUOTED in a command line.
|
|
95
95
|
* Workspace commands are executed as a direct argv spawn — never through a
|
|
@@ -165,7 +165,7 @@ function tokenizeCommand(command) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
//#endregion
|
|
168
|
-
//#region
|
|
168
|
+
//#region ../../packages/ai-workspace/src/policy/policy.ts
|
|
169
169
|
/**
|
|
170
170
|
* Resolve the canonical absolute form of `target`, collapsing any
|
|
171
171
|
* symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),
|
|
@@ -351,7 +351,7 @@ function buildEnv(policy) {
|
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
//#endregion
|
|
354
|
-
//#region
|
|
354
|
+
//#region ../../packages/ai-workspace/src/ops.ts
|
|
355
355
|
/** Default line window a read returns when the policy sets no `defaultLines`. */
|
|
356
356
|
const DEFAULT_READ_LINES = 2e3;
|
|
357
357
|
/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
|
|
@@ -359,6 +359,12 @@ const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
|
359
359
|
/** Default per-command output byte cap when the policy sets none. */
|
|
360
360
|
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
361
361
|
/**
|
|
362
|
+
* Exit code reported for a command refused because its signal was already
|
|
363
|
+
* aborted (the shell convention for an interrupted command). Callers should
|
|
364
|
+
* branch on `cancelled`, not on this number.
|
|
365
|
+
*/
|
|
366
|
+
const CANCELLED_BEFORE_START_EXIT_CODE = 130;
|
|
367
|
+
/**
|
|
362
368
|
* Hard ceiling on `grep` pattern length. A model-controlled regex has no
|
|
363
369
|
* legitimate reason to be this long; longer patterns are rejected outright
|
|
364
370
|
* rather than compiled.
|
|
@@ -372,33 +378,76 @@ const MAX_GREP_PATTERN_LENGTH = 200;
|
|
|
372
378
|
* this are skipped rather than scanned.
|
|
373
379
|
*/
|
|
374
380
|
const MAX_GREP_LINE_SCAN_LENGTH = 2e3;
|
|
375
|
-
/**
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
* number of the first line in the window.
|
|
379
|
-
*/
|
|
380
|
-
function numberLines(content, startLine) {
|
|
381
|
-
return content.split("\n").map((line, index) => {
|
|
382
|
-
const lineNumber = startLine + index;
|
|
383
|
-
return `${String(lineNumber).padStart(6, " ")}\t${line}`;
|
|
384
|
-
}).join("\n");
|
|
381
|
+
/** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */
|
|
382
|
+
function linePrefix(lineNumber) {
|
|
383
|
+
return `${String(lineNumber).padStart(6, " ")}\t`;
|
|
385
384
|
}
|
|
386
385
|
/**
|
|
387
|
-
* Clip
|
|
388
|
-
*
|
|
386
|
+
* Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a
|
|
387
|
+
* character: the cut backs off over continuation bytes (`10xxxxxx`) to the
|
|
388
|
+
* start of the character it would have split, so the result never carries
|
|
389
|
+
* a replacement character.
|
|
389
390
|
*/
|
|
390
|
-
function
|
|
391
|
+
function clipUtf8(value, maxBytes) {
|
|
391
392
|
const bytes = Buffer.from(value, "utf8");
|
|
392
393
|
if (bytes.byteLength <= maxBytes) return {
|
|
393
394
|
value,
|
|
394
395
|
truncated: false
|
|
395
396
|
};
|
|
397
|
+
let cut = Math.max(0, maxBytes);
|
|
398
|
+
while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
|
|
396
399
|
return {
|
|
397
|
-
value: bytes.subarray(0,
|
|
400
|
+
value: bytes.subarray(0, cut).toString("utf8"),
|
|
398
401
|
truncated: true
|
|
399
402
|
};
|
|
400
403
|
}
|
|
401
404
|
/**
|
|
405
|
+
* Number a window of lines `cat -n` style, keeping the joined output within
|
|
406
|
+
* `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.
|
|
407
|
+
* A line that does not fit whole is clipped on a character boundary; it is
|
|
408
|
+
* still counted as returned when its prefix and at least one character fit.
|
|
409
|
+
*/
|
|
410
|
+
function numberWindow(lines, startLine, maxBytes) {
|
|
411
|
+
const parts = [];
|
|
412
|
+
let used = 0;
|
|
413
|
+
for (let index = 0; index < lines.length; index++) {
|
|
414
|
+
const prefix = linePrefix(startLine + index);
|
|
415
|
+
const numbered = prefix + lines[index];
|
|
416
|
+
const separator = parts.length > 0 ? 1 : 0;
|
|
417
|
+
if (maxBytes === void 0) {
|
|
418
|
+
parts.push(numbered);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const cost = separator + Buffer.byteLength(numbered, "utf8");
|
|
422
|
+
if (used + cost <= maxBytes) {
|
|
423
|
+
parts.push(numbered);
|
|
424
|
+
used += cost;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const room = maxBytes - used - separator - Buffer.byteLength(prefix, "utf8");
|
|
428
|
+
const clipped = room > 0 ? clipUtf8(lines[index] ?? "", room).value : "";
|
|
429
|
+
if (clipped.length > 0) parts.push(prefix + clipped);
|
|
430
|
+
return {
|
|
431
|
+
content: parts.join("\n"),
|
|
432
|
+
returnedLines: parts.length,
|
|
433
|
+
bytesCapped: true
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
content: parts.join("\n"),
|
|
438
|
+
returnedLines: parts.length,
|
|
439
|
+
bytesCapped: false
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a
|
|
444
|
+
* character boundary. Returns the possibly-clipped string plus whether
|
|
445
|
+
* clipping occurred.
|
|
446
|
+
*/
|
|
447
|
+
function capOutput(value, maxBytes) {
|
|
448
|
+
return clipUtf8(value, maxBytes);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
402
451
|
* Minimal glob match over a `/`-separated relative path. Supports `**`
|
|
403
452
|
* (spans separators), `*` (within a segment), and `?` (one non-separator
|
|
404
453
|
* char) — enough for the workspace's `glob`/grep narrowing without
|
|
@@ -457,6 +506,24 @@ var Ops = class {
|
|
|
457
506
|
constructor(backend, policy) {
|
|
458
507
|
this.backend = backend;
|
|
459
508
|
this.policy = policy;
|
|
509
|
+
this.pathLocks = /* @__PURE__ */ new Map();
|
|
510
|
+
}
|
|
511
|
+
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
512
|
+
async withPathLock(absolutePath, task) {
|
|
513
|
+
const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();
|
|
514
|
+
let release = () => {};
|
|
515
|
+
const current = new Promise((resolve) => {
|
|
516
|
+
release = resolve;
|
|
517
|
+
});
|
|
518
|
+
const tail = previous.then(() => current);
|
|
519
|
+
this.pathLocks.set(absolutePath, tail);
|
|
520
|
+
try {
|
|
521
|
+
await previous;
|
|
522
|
+
return await task();
|
|
523
|
+
} finally {
|
|
524
|
+
release();
|
|
525
|
+
if (this.pathLocks.get(absolutePath) === tail) this.pathLocks.delete(absolutePath);
|
|
526
|
+
}
|
|
460
527
|
}
|
|
461
528
|
/**
|
|
462
529
|
* Recursively collect every file under `absDir` as absolute paths,
|
|
@@ -491,25 +558,39 @@ var Ops = class {
|
|
|
491
558
|
const totalLines = lines.length;
|
|
492
559
|
const offset = Math.max(1, opts?.offset ?? 1);
|
|
493
560
|
const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
|
|
561
|
+
const maxBytes = this.policy.read?.maxBytes;
|
|
494
562
|
const startIndex = offset - 1;
|
|
563
|
+
const { content, returnedLines, bytesCapped } = numberWindow(lines.slice(startIndex, startIndex + Math.max(0, limit)), offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
|
|
564
|
+
const endLine = offset + returnedLines - 1;
|
|
495
565
|
return {
|
|
496
|
-
content
|
|
497
|
-
|
|
498
|
-
|
|
566
|
+
content,
|
|
567
|
+
startLine: offset,
|
|
568
|
+
endLine,
|
|
569
|
+
totalLines,
|
|
570
|
+
truncated: bytesCapped || endLine < totalLines,
|
|
571
|
+
bytesCapped,
|
|
572
|
+
totalBytes: Buffer.byteLength(raw, "utf8"),
|
|
573
|
+
hash
|
|
499
574
|
};
|
|
500
575
|
}
|
|
501
576
|
async writeFile(inputPath, content) {
|
|
502
577
|
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
578
|
+
return this.withPathLock(absolutePath, async () => {
|
|
579
|
+
const parent = node_path.default.dirname(absolutePath);
|
|
580
|
+
await this.backend.mkdir(parent);
|
|
581
|
+
await this.backend.writeFile(absolutePath, content);
|
|
582
|
+
return {
|
|
583
|
+
hash: _warlock_js_fs.fs.hash.string(content),
|
|
584
|
+
bytesWritten: Buffer.byteLength(content, "utf8")
|
|
585
|
+
};
|
|
586
|
+
});
|
|
510
587
|
}
|
|
511
588
|
async editFile(input) {
|
|
512
589
|
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
590
|
+
return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
|
|
591
|
+
}
|
|
592
|
+
/** The read-check-replace-write of {@link editFile}, run under its path lock. */
|
|
593
|
+
async applyEdit(input, absolutePath, relativePath) {
|
|
513
594
|
const current = await this.backend.readFile(absolutePath);
|
|
514
595
|
const currentHash = _warlock_js_fs.fs.hash.string(current);
|
|
515
596
|
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
|
|
@@ -542,22 +623,34 @@ var Ops = class {
|
|
|
542
623
|
type: "denied-command",
|
|
543
624
|
command
|
|
544
625
|
});
|
|
626
|
+
const signal = opts?.signal;
|
|
627
|
+
if (signal?.aborted) return {
|
|
628
|
+
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
629
|
+
stdout: "",
|
|
630
|
+
stderr: "Command was not executed: the invocation was cancelled before it started.",
|
|
631
|
+
truncated: false,
|
|
632
|
+
timedOut: false,
|
|
633
|
+
cancelled: true
|
|
634
|
+
};
|
|
545
635
|
const shell = this.policy.shell;
|
|
546
636
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
547
637
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
548
638
|
const result = await this.backend.exec(command, {
|
|
549
639
|
cwd: this.policy.cwd,
|
|
550
640
|
timeoutMs,
|
|
551
|
-
env: buildEnv(this.policy)
|
|
641
|
+
env: buildEnv(this.policy),
|
|
642
|
+
...signal ? { signal } : {}
|
|
552
643
|
});
|
|
553
644
|
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
554
645
|
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
646
|
+
const cancelled = result.cancelled === true;
|
|
555
647
|
return {
|
|
556
648
|
exitCode: result.exitCode,
|
|
557
649
|
stdout: stdout.value,
|
|
558
650
|
stderr: stderr.value,
|
|
559
651
|
truncated: stdout.truncated || stderr.truncated,
|
|
560
|
-
timedOut: result.timedOut
|
|
652
|
+
timedOut: result.timedOut && !cancelled,
|
|
653
|
+
cancelled
|
|
561
654
|
};
|
|
562
655
|
}
|
|
563
656
|
async grep(pattern, opts) {
|
|
@@ -625,7 +718,7 @@ var Ops = class {
|
|
|
625
718
|
}
|
|
626
719
|
async remove(inputPath) {
|
|
627
720
|
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
628
|
-
await this.backend.remove(absolutePath);
|
|
721
|
+
await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));
|
|
629
722
|
}
|
|
630
723
|
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
631
724
|
isDenied(relativePath) {
|
|
@@ -682,7 +775,7 @@ function createOps(backend, policy) {
|
|
|
682
775
|
}
|
|
683
776
|
|
|
684
777
|
//#endregion
|
|
685
|
-
//#region
|
|
778
|
+
//#region ../../packages/ai-workspace/src/backends/local.ts
|
|
686
779
|
/**
|
|
687
780
|
* Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw
|
|
688
781
|
* executor truncates each stream once this many bytes have accumulated so a
|
|
@@ -729,7 +822,9 @@ function killTree(pid, child) {
|
|
|
729
822
|
String(pid),
|
|
730
823
|
"/T",
|
|
731
824
|
"/F"
|
|
732
|
-
], { windowsHide: true })
|
|
825
|
+
], { windowsHide: true }).on("error", () => {
|
|
826
|
+
child.kill("SIGKILL");
|
|
827
|
+
});
|
|
733
828
|
return;
|
|
734
829
|
}
|
|
735
830
|
try {
|
|
@@ -846,11 +941,13 @@ var LocalBackend = class {
|
|
|
846
941
|
* cannot be spawned shell-less) with every element individually quoted.
|
|
847
942
|
* `cwd`, `env`, and the timeout are taken verbatim from the ops layer
|
|
848
943
|
* (the environment is NOT merged with `process.env`). On timeout the
|
|
849
|
-
* process is SIGKILLed and `timedOut` is set. `
|
|
850
|
-
*
|
|
944
|
+
* process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
|
|
945
|
+
* mid-run the process tree is SIGKILLed the same way and `cancelled` is
|
|
946
|
+
* set; an already-aborted `signal` resolves `cancelled` without spawning.
|
|
947
|
+
* `stdout`/`stderr` are captured and byte-capped per {@link MAX_STREAM_BYTES}.
|
|
851
948
|
*
|
|
852
949
|
* Never rejects for a non-zero exit, a missing executable, a refused
|
|
853
|
-
* command line, or a
|
|
950
|
+
* command line, a timeout or a cancellation — those are reported through the resolved
|
|
854
951
|
* {@link WorkspaceBackendExecResult} so the ops layer can surface them
|
|
855
952
|
* as tool-error data.
|
|
856
953
|
*/
|
|
@@ -862,12 +959,24 @@ var LocalBackend = class {
|
|
|
862
959
|
stderr,
|
|
863
960
|
timedOut: false
|
|
864
961
|
});
|
|
962
|
+
const signal = opts.signal;
|
|
963
|
+
if (signal?.aborted) {
|
|
964
|
+
resolve({
|
|
965
|
+
exitCode: 130,
|
|
966
|
+
stdout: "",
|
|
967
|
+
stderr: "Command was not executed: the invocation was cancelled before it started.",
|
|
968
|
+
timedOut: false,
|
|
969
|
+
cancelled: true
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
865
973
|
const argv = tokenizeCommand(command);
|
|
866
|
-
|
|
974
|
+
const executable = argv?.[0];
|
|
975
|
+
if (argv === null || executable === void 0) {
|
|
867
976
|
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.");
|
|
868
977
|
return;
|
|
869
978
|
}
|
|
870
|
-
let file =
|
|
979
|
+
let file = executable;
|
|
871
980
|
let args = argv.slice(1);
|
|
872
981
|
let windowsVerbatimArguments = false;
|
|
873
982
|
if (node_process.platform === "win32") {
|
|
@@ -893,20 +1002,31 @@ var LocalBackend = class {
|
|
|
893
1002
|
let stdoutBytes = 0;
|
|
894
1003
|
let stderrBytes = 0;
|
|
895
1004
|
let timedOut = false;
|
|
1005
|
+
let cancelled = false;
|
|
896
1006
|
let settled = false;
|
|
1007
|
+
let exited = false;
|
|
897
1008
|
const timer = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? setTimeout(() => {
|
|
1009
|
+
if (cancelled) return;
|
|
898
1010
|
timedOut = true;
|
|
899
1011
|
killTree(child.pid, child);
|
|
900
1012
|
}, opts.timeoutMs) : void 0;
|
|
1013
|
+
const onAbort = () => {
|
|
1014
|
+
if (settled || timedOut) return;
|
|
1015
|
+
if (!exited) cancelled = true;
|
|
1016
|
+
killTree(child.pid, child);
|
|
1017
|
+
};
|
|
1018
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
901
1019
|
const settle = (exitCode) => {
|
|
902
1020
|
if (settled) return;
|
|
903
1021
|
settled = true;
|
|
904
1022
|
if (timer !== void 0) clearTimeout(timer);
|
|
1023
|
+
signal?.removeEventListener("abort", onAbort);
|
|
905
1024
|
resolve({
|
|
906
1025
|
exitCode,
|
|
907
1026
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
908
1027
|
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
909
|
-
timedOut
|
|
1028
|
+
timedOut,
|
|
1029
|
+
cancelled
|
|
910
1030
|
});
|
|
911
1031
|
};
|
|
912
1032
|
child.stdout?.on("data", (chunk) => {
|
|
@@ -918,6 +1038,9 @@ var LocalBackend = class {
|
|
|
918
1038
|
child.on("error", () => {
|
|
919
1039
|
settle(127);
|
|
920
1040
|
});
|
|
1041
|
+
child.on("exit", () => {
|
|
1042
|
+
exited = true;
|
|
1043
|
+
});
|
|
921
1044
|
child.on("close", (code, signal) => {
|
|
922
1045
|
if (code === null) {
|
|
923
1046
|
settle(128 + (signal === "SIGKILL" ? 9 : 1));
|
|
@@ -948,7 +1071,7 @@ function createLocalBackend() {
|
|
|
948
1071
|
}
|
|
949
1072
|
|
|
950
1073
|
//#endregion
|
|
951
|
-
//#region
|
|
1074
|
+
//#region ../../packages/ai-workspace/src/backends/mock.ts
|
|
952
1075
|
/**
|
|
953
1076
|
* Normalize an absolute path to a stable in-memory key: forward slashes,
|
|
954
1077
|
* collapsed duplicate separators, and resolved `.` / `..` segments. There
|
|
@@ -1048,13 +1171,21 @@ var MockBackend = class {
|
|
|
1048
1171
|
async realpath(absPath) {
|
|
1049
1172
|
return canonicalize(absPath);
|
|
1050
1173
|
}
|
|
1051
|
-
async exec(command,
|
|
1174
|
+
async exec(command, opts) {
|
|
1175
|
+
if (opts?.signal?.aborted) return {
|
|
1176
|
+
exitCode: 130,
|
|
1177
|
+
stdout: "",
|
|
1178
|
+
stderr: "",
|
|
1179
|
+
timedOut: false,
|
|
1180
|
+
cancelled: true
|
|
1181
|
+
};
|
|
1052
1182
|
const scripted = this.commands.get(command);
|
|
1053
1183
|
return {
|
|
1054
1184
|
exitCode: scripted?.exitCode ?? 0,
|
|
1055
1185
|
stdout: scripted?.stdout ?? "",
|
|
1056
1186
|
stderr: scripted?.stderr ?? "",
|
|
1057
|
-
timedOut: scripted?.timedOut ?? false
|
|
1187
|
+
timedOut: scripted?.timedOut ?? false,
|
|
1188
|
+
cancelled: scripted?.cancelled ?? false
|
|
1058
1189
|
};
|
|
1059
1190
|
}
|
|
1060
1191
|
};
|
|
@@ -1113,7 +1244,7 @@ function isMockBackendSeed(seed) {
|
|
|
1113
1244
|
}
|
|
1114
1245
|
|
|
1115
1246
|
//#endregion
|
|
1116
|
-
//#region
|
|
1247
|
+
//#region ../../packages/ai-workspace/src/tools/schema.ts
|
|
1117
1248
|
/**
|
|
1118
1249
|
* Tiny, dependency-free [Standard Schema](https://standardschema.dev)
|
|
1119
1250
|
* builders for the workspace tools' input validation. The package pins
|
|
@@ -1222,7 +1353,7 @@ function objectSchema(shape) {
|
|
|
1222
1353
|
}
|
|
1223
1354
|
|
|
1224
1355
|
//#endregion
|
|
1225
|
-
//#region
|
|
1356
|
+
//#region ../../packages/ai-workspace/src/tools/edit-file.ts
|
|
1226
1357
|
/** Default tool name exposed to the LLM. */
|
|
1227
1358
|
const DEFAULT_NAME$2 = "edit_file";
|
|
1228
1359
|
/** Input schema for the `edit_file` tool. */
|
|
@@ -1274,7 +1405,7 @@ function makeEditFileTool(ops, options) {
|
|
|
1274
1405
|
}
|
|
1275
1406
|
|
|
1276
1407
|
//#endregion
|
|
1277
|
-
//#region
|
|
1408
|
+
//#region ../../packages/ai-workspace/src/tools/glob.ts
|
|
1278
1409
|
/**
|
|
1279
1410
|
* Standard Schema for {@link GlobInput} — a single required `pattern`
|
|
1280
1411
|
* string. Built on the package's shared, dependency-free schema builders
|
|
@@ -1314,7 +1445,7 @@ function makeGlobTool(ops, options) {
|
|
|
1314
1445
|
}
|
|
1315
1446
|
|
|
1316
1447
|
//#endregion
|
|
1317
|
-
//#region
|
|
1448
|
+
//#region ../../packages/ai-workspace/src/tools/grep.ts
|
|
1318
1449
|
/**
|
|
1319
1450
|
* Standard Schema for {@link GrepInput} — `pattern` is a required string;
|
|
1320
1451
|
* `glob` and `ignoreCase` are optional. Built on the package's shared,
|
|
@@ -1360,7 +1491,7 @@ function makeGrepTool(ops, options) {
|
|
|
1360
1491
|
}
|
|
1361
1492
|
|
|
1362
1493
|
//#endregion
|
|
1363
|
-
//#region
|
|
1494
|
+
//#region ../../packages/ai-workspace/src/tools/read-file.ts
|
|
1364
1495
|
/** Default tool name exposed to the LLM. */
|
|
1365
1496
|
const DEFAULT_NAME$1 = "read_file";
|
|
1366
1497
|
/** Input schema for the `read_file` tool. */
|
|
@@ -1375,10 +1506,10 @@ const inputSchema$1 = objectSchema({
|
|
|
1375
1506
|
*
|
|
1376
1507
|
* The tool validates `{ path, startLine?, limit? }` against a Standard
|
|
1377
1508
|
* Schema, then delegates to `ops.readFile`, mapping the result into the
|
|
1378
|
-
* agent wire shape {@link ReadFileResult} — the `hash` an
|
|
1379
|
-
* carry into a later `edit_file` (read-before-edit), plus the
|
|
1380
|
-
* / `endLine` / `truncated` window metadata
|
|
1381
|
-
*
|
|
1509
|
+
* agent wire shape {@link ReadFileResult} — the full file's `hash` an
|
|
1510
|
+
* agent must carry into a later `edit_file` (read-before-edit), plus the
|
|
1511
|
+
* `startLine` / `endLine` / `truncated` / `bytesCapped` window metadata.
|
|
1512
|
+
* The returned `content` never exceeds the policy's `read.maxBytes`.
|
|
1382
1513
|
*
|
|
1383
1514
|
* **Errors flow as data.** Policy violations (a jail escape) are thrown
|
|
1384
1515
|
* by `ops`; the `tool()` wrapper catches them and surfaces them in the
|
|
@@ -1396,30 +1527,19 @@ const inputSchema$1 = objectSchema({
|
|
|
1396
1527
|
function makeReadFileTool(ops, options) {
|
|
1397
1528
|
return (0, _warlock_js_ai.tool)({
|
|
1398
1529
|
name: options?.name ?? DEFAULT_NAME$1,
|
|
1399
|
-
description: "Read a file from the workspace, returning a numbered line window plus the file's content hash. Pass the hash to edit_file's expectHash to guard against editing a stale version. Use startLine/limit to page through large files.",
|
|
1530
|
+
description: "Read a file from the workspace, returning a numbered line window plus the file's content hash. Pass the hash to edit_file's expectHash to guard against editing a stale version. Use startLine/limit to page through large files. Output is byte-capped by the workspace policy; when bytesCapped is true the last returned line may be cut short.",
|
|
1400
1531
|
input: inputSchema$1,
|
|
1401
1532
|
async execute(input) {
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
offset: startLine,
|
|
1533
|
+
return ops.readFile(input.path, {
|
|
1534
|
+
offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
|
|
1405
1535
|
limit: input.limit
|
|
1406
1536
|
});
|
|
1407
|
-
const returnedLines = content.length === 0 ? 0 : content.split("\n").length;
|
|
1408
|
-
const endLine = Math.min(totalLines, startLine + Math.max(returnedLines, 1) - 1);
|
|
1409
|
-
return {
|
|
1410
|
-
content,
|
|
1411
|
-
startLine,
|
|
1412
|
-
endLine,
|
|
1413
|
-
totalLines,
|
|
1414
|
-
truncated: endLine < totalLines,
|
|
1415
|
-
hash
|
|
1416
|
-
};
|
|
1417
1537
|
}
|
|
1418
1538
|
});
|
|
1419
1539
|
}
|
|
1420
1540
|
|
|
1421
1541
|
//#endregion
|
|
1422
|
-
//#region
|
|
1542
|
+
//#region ../../packages/ai-workspace/src/tools/run-shell.ts
|
|
1423
1543
|
/** The default tool name `run_shell` is exposed to the LLM under. */
|
|
1424
1544
|
const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
|
|
1425
1545
|
/**
|
|
@@ -1460,6 +1580,10 @@ const runShellInputSchema = { "~standard": {
|
|
|
1460
1580
|
* but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
|
|
1461
1581
|
* in `data` for the agent to inspect.
|
|
1462
1582
|
*
|
|
1583
|
+
* The agent run's cancellation `signal` (from the dispatch `ctx`) is passed
|
|
1584
|
+
* to `ops.exec` per invocation: a cancelled run never starts the command,
|
|
1585
|
+
* and one cancelled mid-command kills its process tree (`cancelled: true`).
|
|
1586
|
+
*
|
|
1463
1587
|
* @param ops - The policy-enforced operation layer to delegate `exec` to.
|
|
1464
1588
|
* @param options - Optional tool-name override.
|
|
1465
1589
|
*
|
|
@@ -1475,12 +1599,15 @@ function makeRunShellTool(ops, options) {
|
|
|
1475
1599
|
description: "Run a single shell command inside the workspace. The command's executable must be permitted by the shell policy; output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error.",
|
|
1476
1600
|
action: (input) => `Running \`${input.command}\``,
|
|
1477
1601
|
input: runShellInputSchema,
|
|
1478
|
-
execute: (input) => ops.exec(input.command, {
|
|
1602
|
+
execute: (input, ctx) => ops.exec(input.command, {
|
|
1603
|
+
timeoutMs: input.timeoutMs,
|
|
1604
|
+
...ctx?.signal ? { signal: ctx.signal } : {}
|
|
1605
|
+
})
|
|
1479
1606
|
});
|
|
1480
1607
|
}
|
|
1481
1608
|
|
|
1482
1609
|
//#endregion
|
|
1483
|
-
//#region
|
|
1610
|
+
//#region ../../packages/ai-workspace/src/tools/run-tests.ts
|
|
1484
1611
|
/** The default tool name `run_tests` is exposed to the LLM under. */
|
|
1485
1612
|
const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
|
|
1486
1613
|
/** The default command run when no `command` override is configured. */
|
|
@@ -1527,7 +1654,8 @@ const runTestsInputSchema = { "~standard": {
|
|
|
1527
1654
|
* data, never a second command. Like `run_shell`, the resolved command's
|
|
1528
1655
|
* executable is gated by the shell policy — a denial surfaces in the
|
|
1529
1656
|
* result's `error` field — and a non-zero exit (failing tests) comes back
|
|
1530
|
-
* as `data` for the agent to read and fix.
|
|
1657
|
+
* as `data` for the agent to read and fix. The agent run's cancellation
|
|
1658
|
+
* `signal` is threaded per invocation, exactly as for `run_shell`.
|
|
1531
1659
|
*
|
|
1532
1660
|
* @param ops - The policy-enforced operation layer to delegate `exec` to.
|
|
1533
1661
|
* @param options - Optional tool-name and base-command overrides.
|
|
@@ -1544,15 +1672,15 @@ function makeRunTestsTool(ops, options) {
|
|
|
1544
1672
|
description: "Run the workspace's test suite, optionally narrowed to a path or name pattern forwarded to the test runner. Failing tests return a non-zero exit code as data, not an error.",
|
|
1545
1673
|
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
1546
1674
|
input: runTestsInputSchema,
|
|
1547
|
-
execute: (input) => {
|
|
1675
|
+
execute: (input, ctx) => {
|
|
1548
1676
|
const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
|
|
1549
|
-
return ops.exec(command);
|
|
1677
|
+
return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
|
|
1550
1678
|
}
|
|
1551
1679
|
});
|
|
1552
1680
|
}
|
|
1553
1681
|
|
|
1554
1682
|
//#endregion
|
|
1555
|
-
//#region
|
|
1683
|
+
//#region ../../packages/ai-workspace/src/tools/write-file.ts
|
|
1556
1684
|
/** Default tool name exposed to the LLM. */
|
|
1557
1685
|
const DEFAULT_NAME = "write_file";
|
|
1558
1686
|
/** Input schema for the `write_file` tool. */
|
|
@@ -1599,7 +1727,7 @@ function makeWriteFileTool(ops, options) {
|
|
|
1599
1727
|
}
|
|
1600
1728
|
|
|
1601
1729
|
//#endregion
|
|
1602
|
-
//#region
|
|
1730
|
+
//#region ../../packages/ai-workspace/src/workspace.ts
|
|
1603
1731
|
/**
|
|
1604
1732
|
* The full set of tool names a writable workspace vends, in a stable
|
|
1605
1733
|
* canonical order so `tools.all()` is deterministic.
|