@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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/cjs/index.cjs +514 -54
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +24 -1
- package/esm/backends/local.mjs.map +1 -1
- package/esm/contracts/index.d.mts +4 -4
- package/esm/contracts/tool-io.type.d.mts +6 -0
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +20 -0
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-edit.type.d.mts +30 -0
- package/esm/contracts/workspace-edit.type.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +7 -2
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +23 -0
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +4 -1
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/hooks/shell-output.mjs +113 -0
- package/esm/hooks/shell-output.mjs.map +1 -0
- package/esm/index.d.mts +4 -4
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +108 -40
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +2 -0
- package/esm/policy/shell-check.d.mts.map +1 -1
- package/esm/policy/shell-check.mjs +59 -5
- package/esm/policy/shell-check.mjs.map +1 -1
- package/esm/search/walk.mjs +209 -0
- package/esm/search/walk.mjs.map +1 -0
- package/esm/shell-jobs.d.mts +1 -1
- package/esm/shell-jobs.d.mts.map +1 -1
- package/esm/shell-jobs.mjs +1 -1
- package/esm/shell-jobs.mjs.map +1 -1
- package/esm/tools/glob.mjs +5 -4
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.mjs +3 -2
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +2 -1
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/workspace.mjs +2 -2
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +36 -17
- package/package.json +3 -3
- package/skills/use-a-workspace/SKILL.md +36 -17
package/cjs/index.cjs
CHANGED
|
@@ -2147,6 +2147,53 @@ var Refusal = class extends Error {
|
|
|
2147
2147
|
this.refusedPath = refusedPath;
|
|
2148
2148
|
}
|
|
2149
2149
|
};
|
|
2150
|
+
/**
|
|
2151
|
+
* The first word of `command` that has an unquoted backslash before a
|
|
2152
|
+
* letter, digit or dot inside it (a Windows-style path bash reads as
|
|
2153
|
+
* escapes), with the same word spelled with forward slashes. Quoted text
|
|
2154
|
+
* and `\ ` style escapes (a backslash before a space, quote, `$` and the
|
|
2155
|
+
* like) are left alone.
|
|
2156
|
+
*/
|
|
2157
|
+
function findWindowsStylePath(command) {
|
|
2158
|
+
let quote = null;
|
|
2159
|
+
let start = 0;
|
|
2160
|
+
let found = false;
|
|
2161
|
+
let suggestion = "";
|
|
2162
|
+
const finish = (end) => found ? {
|
|
2163
|
+
word: command.slice(start, end),
|
|
2164
|
+
suggestion
|
|
2165
|
+
} : null;
|
|
2166
|
+
for (let index = 0; index < command.length; index++) {
|
|
2167
|
+
const char = command[index];
|
|
2168
|
+
if (quote === null && /[\s;&|<>()]/.test(char)) {
|
|
2169
|
+
const hit = finish(index);
|
|
2170
|
+
if (hit !== null) return hit;
|
|
2171
|
+
start = index + 1;
|
|
2172
|
+
found = false;
|
|
2173
|
+
suggestion = "";
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
if (quote === "'") {
|
|
2177
|
+
suggestion += char;
|
|
2178
|
+
quote = char === "'" ? null : quote;
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
if (char === "\\") {
|
|
2182
|
+
const next = command[index + 1] ?? "";
|
|
2183
|
+
if (quote === null && index > start && /[A-Za-z0-9.]/.test(next)) {
|
|
2184
|
+
found = true;
|
|
2185
|
+
suggestion += "/";
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
suggestion += char + next;
|
|
2189
|
+
index++;
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
if (char === "\"" || char === "'") quote = quote === null ? char : quote === char ? null : quote;
|
|
2193
|
+
suggestion += char;
|
|
2194
|
+
}
|
|
2195
|
+
return finish(command.length);
|
|
2196
|
+
}
|
|
2150
2197
|
/** Per-line checker state. */
|
|
2151
2198
|
var LineChecker = class {
|
|
2152
2199
|
constructor(policy, shell, line, dialect, interpreter, remembered = []) {
|
|
@@ -2636,9 +2683,14 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
|
|
|
2636
2683
|
*/
|
|
2637
2684
|
function checkShellCommand(policy, command, options) {
|
|
2638
2685
|
const resolved = options?.resolved ?? resolveShell(policy.shell, options?.host ?? currentShellHost());
|
|
2639
|
-
return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered);
|
|
2686
|
+
return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered, options?.platform ?? options?.host?.platform ?? process.platform);
|
|
2640
2687
|
}
|
|
2641
|
-
|
|
2688
|
+
/** Bash on Windows reads `ab\\x` as an escape, not a path: refuse and show the forward-slash form. */
|
|
2689
|
+
function refuseWindowsStylePath(command) {
|
|
2690
|
+
const hit = findWindowsStylePath(command);
|
|
2691
|
+
if (hit !== null) throw new Refusal("unsupported-syntax", `\`${hit.word}\` uses Windows-style backslashes, which bash reads as escapes and so runs a different path. Write the path with forward slashes: \`${hit.suggestion}\` (or quote it).`);
|
|
2692
|
+
}
|
|
2693
|
+
function runCheck(policy, command, interpreter, dialect, remembered, platform = process.platform) {
|
|
2642
2694
|
const shell = policy.shell;
|
|
2643
2695
|
if (!shell) return {
|
|
2644
2696
|
allowed: false,
|
|
@@ -2652,8 +2704,10 @@ function runCheck(policy, command, interpreter, dialect, remembered) {
|
|
|
2652
2704
|
const checker = new LineChecker(policy, shell, command, shellMode ? dialect : "posix", interpreter, remembered);
|
|
2653
2705
|
try {
|
|
2654
2706
|
if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
|
|
2655
|
-
if (shellMode)
|
|
2656
|
-
|
|
2707
|
+
if (shellMode) {
|
|
2708
|
+
if (interpreter === "bash" && platform === "win32") refuseWindowsStylePath(command);
|
|
2709
|
+
checkShellLine(checker, policy, command, dialect);
|
|
2710
|
+
} else {
|
|
2657
2711
|
const argv = tokenizeCommand(command);
|
|
2658
2712
|
if (argv === null) throw argvModeRefusal(command);
|
|
2659
2713
|
checker.checkArgv(argv, argv.map(() => false), 0);
|
|
@@ -3667,6 +3721,117 @@ async function runAfterEdit(policy, event) {
|
|
|
3667
3721
|
}
|
|
3668
3722
|
}
|
|
3669
3723
|
|
|
3724
|
+
//#endregion
|
|
3725
|
+
//#region ../../packages/ai-workspace/src/hooks/shell-output.ts
|
|
3726
|
+
/** Shortest gap between two deliveries to the hook (about 20 a second). */
|
|
3727
|
+
const MIN_INTERVAL_MS = 50;
|
|
3728
|
+
/** A line this long without a newline is delivered anyway (minus the tail kept back). */
|
|
3729
|
+
const MAX_PENDING_CHARS = 16384;
|
|
3730
|
+
/** Characters of an over-long line held back, so a secret on the cut is still whole next time. */
|
|
3731
|
+
const HOLD_BACK_CHARS = 512;
|
|
3732
|
+
/** Shortest `shell.env` value masked automatically. */
|
|
3733
|
+
const MIN_SECRET_LENGTH = 8;
|
|
3734
|
+
/**
|
|
3735
|
+
* Feeds a command's output to `hooks.onShellOutput`.
|
|
3736
|
+
*
|
|
3737
|
+
* Redaction needs a whole secret in one piece, and a process writes in
|
|
3738
|
+
* arbitrary pieces, so output is buffered to line boundaries before it is
|
|
3739
|
+
* redacted and delivered; the unfinished last line waits until its newline
|
|
3740
|
+
* arrives or the command ends (an over-long line is cut, keeping its last
|
|
3741
|
+
* characters back). Deliveries are merged to at most one per
|
|
3742
|
+
* {@link MIN_INTERVAL_MS} and the hook can never throw into the command.
|
|
3743
|
+
*/
|
|
3744
|
+
var ShellOutputStreamer = class ShellOutputStreamer {
|
|
3745
|
+
constructor(hooks, source, env) {
|
|
3746
|
+
this.hooks = hooks;
|
|
3747
|
+
this.source = source;
|
|
3748
|
+
this.received = false;
|
|
3749
|
+
this.pending = {
|
|
3750
|
+
stdout: "",
|
|
3751
|
+
stderr: ""
|
|
3752
|
+
};
|
|
3753
|
+
this.ready = {
|
|
3754
|
+
stdout: "",
|
|
3755
|
+
stderr: ""
|
|
3756
|
+
};
|
|
3757
|
+
this.lastEmit = 0;
|
|
3758
|
+
this.ended = false;
|
|
3759
|
+
this.secrets = Object.values(env ?? {}).filter((value) => value.length >= MIN_SECRET_LENGTH).sort((a, b) => b.length - a.length);
|
|
3760
|
+
}
|
|
3761
|
+
/** A streamer when the policy has `onShellOutput`, otherwise `undefined`. */
|
|
3762
|
+
static create(hooks, source, env) {
|
|
3763
|
+
return hooks?.onShellOutput === void 0 ? void 0 : new ShellOutputStreamer(hooks, source, env);
|
|
3764
|
+
}
|
|
3765
|
+
/** Add decoded output of one stream. */
|
|
3766
|
+
push(stream, text) {
|
|
3767
|
+
if (this.ended || text === "") return;
|
|
3768
|
+
this.received = true;
|
|
3769
|
+
let pending = this.pending[stream] + text;
|
|
3770
|
+
const newline = pending.lastIndexOf("\n");
|
|
3771
|
+
if (newline >= 0) {
|
|
3772
|
+
this.ready[stream] += pending.slice(0, newline + 1);
|
|
3773
|
+
pending = pending.slice(newline + 1);
|
|
3774
|
+
}
|
|
3775
|
+
if (pending.length > MAX_PENDING_CHARS) {
|
|
3776
|
+
this.ready[stream] += pending.slice(0, pending.length - HOLD_BACK_CHARS);
|
|
3777
|
+
pending = pending.slice(pending.length - HOLD_BACK_CHARS);
|
|
3778
|
+
}
|
|
3779
|
+
this.pending[stream] = pending;
|
|
3780
|
+
this.schedule();
|
|
3781
|
+
}
|
|
3782
|
+
/** Deliver everything left, including unfinished lines, and stop. */
|
|
3783
|
+
end() {
|
|
3784
|
+
if (this.ended) return;
|
|
3785
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
3786
|
+
this.ready[stream] += this.pending[stream];
|
|
3787
|
+
this.pending[stream] = "";
|
|
3788
|
+
}
|
|
3789
|
+
this.clearTimer();
|
|
3790
|
+
this.flush();
|
|
3791
|
+
this.ended = true;
|
|
3792
|
+
}
|
|
3793
|
+
schedule() {
|
|
3794
|
+
if (this.timer !== void 0) return;
|
|
3795
|
+
const wait = Math.max(0, this.lastEmit + MIN_INTERVAL_MS - Date.now());
|
|
3796
|
+
this.timer = setTimeout(() => {
|
|
3797
|
+
this.timer = void 0;
|
|
3798
|
+
this.flush();
|
|
3799
|
+
}, wait);
|
|
3800
|
+
this.timer.unref?.();
|
|
3801
|
+
}
|
|
3802
|
+
clearTimer() {
|
|
3803
|
+
if (this.timer !== void 0) {
|
|
3804
|
+
clearTimeout(this.timer);
|
|
3805
|
+
this.timer = void 0;
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
flush() {
|
|
3809
|
+
this.lastEmit = Date.now();
|
|
3810
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
3811
|
+
const text = this.ready[stream];
|
|
3812
|
+
if (text === "") continue;
|
|
3813
|
+
this.ready[stream] = "";
|
|
3814
|
+
this.deliver(stream, text);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
deliver(stream, text) {
|
|
3818
|
+
try {
|
|
3819
|
+
let chunk = text;
|
|
3820
|
+
for (const secret of this.secrets) chunk = chunk.split(secret).join("[redacted]");
|
|
3821
|
+
chunk = this.hooks.redactShellOutput?.(chunk) ?? chunk;
|
|
3822
|
+
if (chunk === "") return;
|
|
3823
|
+
const event = {
|
|
3824
|
+
command: this.source.command,
|
|
3825
|
+
stream,
|
|
3826
|
+
chunk,
|
|
3827
|
+
...this.source.callId !== void 0 ? { callId: this.source.callId } : {},
|
|
3828
|
+
...this.source.jobId !== void 0 ? { jobId: this.source.jobId } : {}
|
|
3829
|
+
};
|
|
3830
|
+
this.hooks.onShellOutput?.(event);
|
|
3831
|
+
} catch {}
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
|
|
3670
3835
|
//#endregion
|
|
3671
3836
|
//#region ../../packages/ai-workspace/src/output/spill.ts
|
|
3672
3837
|
const DEFAULT_MAX_LINES = 2e3;
|
|
@@ -4089,7 +4254,7 @@ var ShellJobs = class {
|
|
|
4089
4254
|
});
|
|
4090
4255
|
const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
|
|
4091
4256
|
this.jobs.set(job.id, job);
|
|
4092
|
-
const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
|
|
4257
|
+
const handle = spec.launch((stream, chunk) => job.append(stream, chunk), job.id);
|
|
4093
4258
|
job.process = handle;
|
|
4094
4259
|
if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
|
|
4095
4260
|
job.timer = setTimeout(() => {
|
|
@@ -4148,6 +4313,210 @@ var ShellJobs = class {
|
|
|
4148
4313
|
}
|
|
4149
4314
|
};
|
|
4150
4315
|
|
|
4316
|
+
//#endregion
|
|
4317
|
+
//#region ../../packages/ai-workspace/src/search/walk.ts
|
|
4318
|
+
/** Directories `glob` and `grep` never descend into unless `search.skipDirs` says otherwise. */
|
|
4319
|
+
const DEFAULT_SEARCH_SKIP_DIRS = [".git", "node_modules"];
|
|
4320
|
+
/** Default most files one `glob` or `grep` walk looks at. */
|
|
4321
|
+
const DEFAULT_SEARCH_MAX_FILES = 5e4;
|
|
4322
|
+
/**
|
|
4323
|
+
* The directory a glob can be anchored at: its leading segments that hold no
|
|
4324
|
+
* wildcard, never counting the last segment (it may be a file). `""` is the root.
|
|
4325
|
+
*/
|
|
4326
|
+
function staticGlobPrefix(pattern) {
|
|
4327
|
+
const segments = pattern.split("/");
|
|
4328
|
+
const kept = [];
|
|
4329
|
+
for (let index = 0; index < segments.length - 1; index++) {
|
|
4330
|
+
const segment = segments[index] ?? "";
|
|
4331
|
+
if (segment === "" || segment === "." || segment === ".." || /[*?[\]{}\\]/.test(segment)) break;
|
|
4332
|
+
kept.push(segment);
|
|
4333
|
+
}
|
|
4334
|
+
return kept.join("/");
|
|
4335
|
+
}
|
|
4336
|
+
/** Parse the lines of a `.gitignore` located at workspace-relative `base`. */
|
|
4337
|
+
function parseGitignore(content, base) {
|
|
4338
|
+
const rules = [];
|
|
4339
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
4340
|
+
let line = raw.trimEnd();
|
|
4341
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
4342
|
+
const negate = line.startsWith("!");
|
|
4343
|
+
if (negate) line = line.slice(1);
|
|
4344
|
+
const dirOnly = line.endsWith("/");
|
|
4345
|
+
if (dirOnly) line = line.slice(0, -1);
|
|
4346
|
+
const anywhere = line.startsWith("**/");
|
|
4347
|
+
if (anywhere) line = line.slice(3);
|
|
4348
|
+
if (line.startsWith("/")) line = line.slice(1);
|
|
4349
|
+
if (line === "") continue;
|
|
4350
|
+
rules.push({
|
|
4351
|
+
base,
|
|
4352
|
+
negate,
|
|
4353
|
+
dirOnly,
|
|
4354
|
+
anywhere,
|
|
4355
|
+
nameOnly: !anywhere && !line.includes("/") && !raw.trimStart().startsWith("/"),
|
|
4356
|
+
regex: globToRegExp(line)
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
return rules;
|
|
4360
|
+
}
|
|
4361
|
+
/** Whether the ordered rules (last match wins) ignore the entry at `rel`. */
|
|
4362
|
+
function isIgnored(rel, name, isDir, rules) {
|
|
4363
|
+
let ignored = false;
|
|
4364
|
+
for (const rule of rules) {
|
|
4365
|
+
if (rule.dirOnly && !isDir) continue;
|
|
4366
|
+
const local = rule.base === "" ? rel : rel.slice(rule.base.length + 1);
|
|
4367
|
+
let hit;
|
|
4368
|
+
if (rule.nameOnly) hit = rule.regex.test(name);
|
|
4369
|
+
else if (rule.anywhere) {
|
|
4370
|
+
const parts = local.split("/");
|
|
4371
|
+
hit = parts.some((_, index) => rule.regex.test(parts.slice(index).join("/")));
|
|
4372
|
+
} else hit = rule.regex.test(local);
|
|
4373
|
+
if (hit) ignored = !rule.negate;
|
|
4374
|
+
}
|
|
4375
|
+
return ignored;
|
|
4376
|
+
}
|
|
4377
|
+
/** Whether a directory name or relative path is one of the `skipDirs` globs. */
|
|
4378
|
+
function isSkipped(name, rel, skipDirs) {
|
|
4379
|
+
return skipDirs.some((glob) => glob.includes("/") ? matchesAnyGlob(rel, [glob]) : globToRegExp(glob).test(name));
|
|
4380
|
+
}
|
|
4381
|
+
/**
|
|
4382
|
+
* List a directory with entry types: the backend's own typed listing when it has
|
|
4383
|
+
* one, else its plain listing plus `stat`, else (no `stat`) a second listing per
|
|
4384
|
+
* entry to tell directories from files.
|
|
4385
|
+
*/
|
|
4386
|
+
async function listTyped(backend, absDir) {
|
|
4387
|
+
if (backend.listTyped !== void 0) return backend.listTyped(absDir);
|
|
4388
|
+
const entries = await backend.list(absDir);
|
|
4389
|
+
return Promise.all(entries.map(async (entry) => {
|
|
4390
|
+
if (backend.stat !== void 0) try {
|
|
4391
|
+
return {
|
|
4392
|
+
path: entry,
|
|
4393
|
+
type: (await backend.stat(entry)).type
|
|
4394
|
+
};
|
|
4395
|
+
} catch {
|
|
4396
|
+
return {
|
|
4397
|
+
path: entry,
|
|
4398
|
+
type: "other"
|
|
4399
|
+
};
|
|
4400
|
+
}
|
|
4401
|
+
try {
|
|
4402
|
+
if ((await backend.list(entry)).length === 0) try {
|
|
4403
|
+
await backend.readFile(entry);
|
|
4404
|
+
return {
|
|
4405
|
+
path: entry,
|
|
4406
|
+
type: "file"
|
|
4407
|
+
};
|
|
4408
|
+
} catch {
|
|
4409
|
+
return {
|
|
4410
|
+
path: entry,
|
|
4411
|
+
type: "directory"
|
|
4412
|
+
};
|
|
4413
|
+
}
|
|
4414
|
+
return {
|
|
4415
|
+
path: entry,
|
|
4416
|
+
type: "directory"
|
|
4417
|
+
};
|
|
4418
|
+
} catch {
|
|
4419
|
+
return {
|
|
4420
|
+
path: entry,
|
|
4421
|
+
type: "file"
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
}));
|
|
4425
|
+
}
|
|
4426
|
+
/** Read `.gitignore` at `absDir`, or nothing when there is none. */
|
|
4427
|
+
async function readGitignore(backend, absDir, base) {
|
|
4428
|
+
try {
|
|
4429
|
+
return parseGitignore(await backend.readFile(node_path.default.join(absDir, ".gitignore")), base);
|
|
4430
|
+
} catch {
|
|
4431
|
+
return [];
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
/**
|
|
4435
|
+
* Walk the files under `startDir` breadth first. Denied, skipped and
|
|
4436
|
+
* gitignored directories are dropped before they are listed, so a tree such as
|
|
4437
|
+
* `node_modules` is never read. Directories are read `concurrency` at a time,
|
|
4438
|
+
* the signal is checked between batches, and the walk stops at `maxFiles`.
|
|
4439
|
+
* Symlinks are never followed; a symlink to a file is reported only when its
|
|
4440
|
+
* target is inside the jail root.
|
|
4441
|
+
*/
|
|
4442
|
+
async function walkFiles(options) {
|
|
4443
|
+
const { backend, jailRoot, startDir, denyPaths, skipDirs, signal } = options;
|
|
4444
|
+
const files = [];
|
|
4445
|
+
let truncated = false;
|
|
4446
|
+
const isDenied = (rel) => denyPaths.length > 0 && matchesAnyGlob(rel, denyPaths);
|
|
4447
|
+
const isDeniedDir = (rel) => isDenied(rel) || denyPaths.some((glob) => glob.endsWith("/**") && globToRegExp(glob.slice(0, -3)).test(rel));
|
|
4448
|
+
const startRel = node_path.default.relative(jailRoot, startDir).split(node_path.default.sep).join("/");
|
|
4449
|
+
let inherited = [];
|
|
4450
|
+
if (options.gitignore) {
|
|
4451
|
+
const segments = startRel === "" ? [] : startRel.split("/");
|
|
4452
|
+
for (let depth = 0; depth < segments.length; depth++) {
|
|
4453
|
+
const base = segments.slice(0, depth).join("/");
|
|
4454
|
+
const abs = base === "" ? jailRoot : node_path.default.join(jailRoot, ...segments.slice(0, depth));
|
|
4455
|
+
inherited = inherited.concat(await readGitignore(backend, abs, base));
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
let level = [{
|
|
4459
|
+
abs: startDir,
|
|
4460
|
+
rel: startRel,
|
|
4461
|
+
rules: inherited
|
|
4462
|
+
}];
|
|
4463
|
+
const readDir = async (dir, next) => {
|
|
4464
|
+
let entries;
|
|
4465
|
+
try {
|
|
4466
|
+
entries = await listTyped(backend, dir.abs);
|
|
4467
|
+
} catch {
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4470
|
+
let rules = dir.rules;
|
|
4471
|
+
if (options.gitignore && entries.some((entry) => node_path.default.basename(entry.path) === ".gitignore" && entry.type !== "directory")) rules = rules.concat(await readGitignore(backend, dir.abs, dir.rel));
|
|
4472
|
+
for (const entry of entries) {
|
|
4473
|
+
if (files.length >= options.maxFiles) {
|
|
4474
|
+
truncated = true;
|
|
4475
|
+
return;
|
|
4476
|
+
}
|
|
4477
|
+
const name = node_path.default.basename(entry.path);
|
|
4478
|
+
const rel = dir.rel === "" ? name : `${dir.rel}/${name}`;
|
|
4479
|
+
let type = entry.type;
|
|
4480
|
+
let abs = entry.path;
|
|
4481
|
+
if (type === "symlink") try {
|
|
4482
|
+
const target = await backend.realpath(entry.path);
|
|
4483
|
+
if (!isInside(target, jailRoot) || backend.stat === void 0) continue;
|
|
4484
|
+
if ((await backend.stat(target)).type !== "file") continue;
|
|
4485
|
+
type = "file";
|
|
4486
|
+
abs = entry.path;
|
|
4487
|
+
} catch {
|
|
4488
|
+
continue;
|
|
4489
|
+
}
|
|
4490
|
+
if (type === "directory") {
|
|
4491
|
+
if (isDeniedDir(rel) || isSkipped(name, rel, skipDirs)) continue;
|
|
4492
|
+
if (options.gitignore && isIgnored(rel, name, true, rules)) continue;
|
|
4493
|
+
next.push({
|
|
4494
|
+
abs,
|
|
4495
|
+
rel,
|
|
4496
|
+
rules
|
|
4497
|
+
});
|
|
4498
|
+
continue;
|
|
4499
|
+
}
|
|
4500
|
+
if (type !== "file" || isDenied(rel)) continue;
|
|
4501
|
+
if (options.gitignore && isIgnored(rel, name, false, rules)) continue;
|
|
4502
|
+
files.push(abs);
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
while (level.length > 0 && !truncated) {
|
|
4506
|
+
const next = [];
|
|
4507
|
+
for (let index = 0; index < level.length && !truncated; index += options.concurrency) {
|
|
4508
|
+
signal?.throwIfAborted();
|
|
4509
|
+
await Promise.all(level.slice(index, index + options.concurrency).map((dir) => readDir(dir, next)));
|
|
4510
|
+
}
|
|
4511
|
+
signal?.throwIfAborted();
|
|
4512
|
+
level = next;
|
|
4513
|
+
}
|
|
4514
|
+
return {
|
|
4515
|
+
files,
|
|
4516
|
+
truncated
|
|
4517
|
+
};
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4151
4520
|
//#endregion
|
|
4152
4521
|
//#region ../../packages/ai-workspace/src/ops.ts
|
|
4153
4522
|
/** Default line window a read returns when the policy sets no `defaultLines`. */
|
|
@@ -4320,20 +4689,35 @@ var Ops = class {
|
|
|
4320
4689
|
}
|
|
4321
4690
|
}
|
|
4322
4691
|
/**
|
|
4323
|
-
*
|
|
4324
|
-
*
|
|
4325
|
-
*
|
|
4692
|
+
* Collect the files a `glob` or `grep` should look at, starting at the
|
|
4693
|
+
* folder the pattern is anchored to (see {@link staticGlobPrefix}).
|
|
4694
|
+
* `denyPaths`, `search.skipDirs` and `.gitignore` are applied before a
|
|
4695
|
+
* directory is listed.
|
|
4326
4696
|
*/
|
|
4327
|
-
async
|
|
4328
|
-
const
|
|
4329
|
-
const
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4697
|
+
async searchFiles(jailRoot, pattern, signal) {
|
|
4698
|
+
const prefix = pattern === void 0 ? "" : staticGlobPrefix(pattern);
|
|
4699
|
+
const search = this.policy.search;
|
|
4700
|
+
let startDir = jailRoot;
|
|
4701
|
+
if (prefix !== "") try {
|
|
4702
|
+
startDir = (await resolveInJail(this.policy, prefix)).absolutePath;
|
|
4703
|
+
} catch (error) {
|
|
4704
|
+
if (error instanceof WorkspacePolicyError) return {
|
|
4705
|
+
files: [],
|
|
4706
|
+
truncated: false
|
|
4707
|
+
};
|
|
4708
|
+
throw error;
|
|
4709
|
+
}
|
|
4710
|
+
return walkFiles({
|
|
4711
|
+
backend: this.backend,
|
|
4712
|
+
jailRoot,
|
|
4713
|
+
startDir,
|
|
4714
|
+
denyPaths: this.policy.denyPaths ?? [],
|
|
4715
|
+
skipDirs: search?.skipDirs ?? DEFAULT_SEARCH_SKIP_DIRS,
|
|
4716
|
+
gitignore: search?.gitignore ?? true,
|
|
4717
|
+
maxFiles: search?.maxFiles ?? 5e4,
|
|
4718
|
+
concurrency: Math.max(1, search?.concurrency ?? 16),
|
|
4719
|
+
...signal ? { signal } : {}
|
|
4720
|
+
});
|
|
4337
4721
|
}
|
|
4338
4722
|
/** Whether an absolute path is a directory, by attempting to list it. */
|
|
4339
4723
|
async isDirectory(absPath) {
|
|
@@ -4572,14 +4956,29 @@ var Ops = class {
|
|
|
4572
4956
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
4573
4957
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
4574
4958
|
const interpreter = this.resolvedShell.interpreter;
|
|
4575
|
-
const
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4959
|
+
const env = buildCommandEnv(this.policy);
|
|
4960
|
+
const live = ShellOutputStreamer.create(this.policy.hooks, {
|
|
4961
|
+
command,
|
|
4962
|
+
...opts?.callId !== void 0 ? { callId: opts.callId } : {}
|
|
4963
|
+
}, this.policy.shell?.env);
|
|
4964
|
+
let result;
|
|
4965
|
+
try {
|
|
4966
|
+
result = await this.backend.exec(command, {
|
|
4967
|
+
cwd: this.policy.cwd,
|
|
4968
|
+
timeoutMs,
|
|
4969
|
+
env,
|
|
4970
|
+
...live ? { onOutput: (stream, chunk) => live.push(stream, chunk) } : {},
|
|
4971
|
+
...signal ? { signal } : {},
|
|
4972
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
4973
|
+
...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
|
|
4974
|
+
});
|
|
4975
|
+
if (live !== void 0 && !live.received) {
|
|
4976
|
+
live.push("stdout", result.stdout);
|
|
4977
|
+
live.push("stderr", result.stderr);
|
|
4978
|
+
}
|
|
4979
|
+
} finally {
|
|
4980
|
+
live?.end();
|
|
4981
|
+
}
|
|
4583
4982
|
const stdout = await this.fitStream("run_shell-stdout", result.stdout, maxOutputBytes);
|
|
4584
4983
|
const stderr = await this.fitStream("run_shell-stderr", result.stderr, maxOutputBytes);
|
|
4585
4984
|
const cancelled = result.cancelled === true;
|
|
@@ -4619,13 +5018,24 @@ var Ops = class {
|
|
|
4619
5018
|
retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
4620
5019
|
maxJobs: background.maxJobs ?? 4,
|
|
4621
5020
|
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
4622
|
-
launch: (onOutput) =>
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
5021
|
+
launch: (onOutput, jobId) => {
|
|
5022
|
+
const live = ShellOutputStreamer.create(this.policy.hooks, {
|
|
5023
|
+
command,
|
|
5024
|
+
jobId
|
|
5025
|
+
}, this.policy.shell?.env);
|
|
5026
|
+
const handle = start(command, {
|
|
5027
|
+
cwd: this.policy.cwd,
|
|
5028
|
+
env,
|
|
5029
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
5030
|
+
...interpreterPath !== void 0 ? { interpreterPath } : {},
|
|
5031
|
+
onOutput: (stream, chunk) => {
|
|
5032
|
+
onOutput(stream, chunk);
|
|
5033
|
+
live?.push(stream, chunk);
|
|
5034
|
+
}
|
|
5035
|
+
});
|
|
5036
|
+
handle.done.then(() => live?.end());
|
|
5037
|
+
return handle;
|
|
5038
|
+
}
|
|
4629
5039
|
});
|
|
4630
5040
|
}
|
|
4631
5041
|
async shellOutput(id, opts) {
|
|
@@ -4760,9 +5170,11 @@ var Ops = class {
|
|
|
4760
5170
|
const flags = opts?.ignoreCase ? "i" : "";
|
|
4761
5171
|
const regex = new RegExp(pattern, flags);
|
|
4762
5172
|
const globRegex = opts?.glob ? globToRegExp(opts.glob) : void 0;
|
|
4763
|
-
const
|
|
5173
|
+
const walked = await this.searchFiles(jailRoot, opts?.glob, opts?.signal);
|
|
5174
|
+
const files = walked.files.sort();
|
|
4764
5175
|
const matches = [];
|
|
4765
5176
|
for (const absFile of files) {
|
|
5177
|
+
opts?.signal?.throwIfAborted();
|
|
4766
5178
|
const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
|
|
4767
5179
|
if (globRegex && !globRegex.test(relativePath)) continue;
|
|
4768
5180
|
if (this.isDenied(relativePath)) continue;
|
|
@@ -4782,14 +5194,22 @@ var Ops = class {
|
|
|
4782
5194
|
line: index + 1,
|
|
4783
5195
|
text: line
|
|
4784
5196
|
});
|
|
4785
|
-
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches);
|
|
5197
|
+
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches, walked.truncated);
|
|
4786
5198
|
}
|
|
4787
5199
|
}
|
|
4788
5200
|
}
|
|
4789
|
-
return this.fitMatches(matches);
|
|
5201
|
+
return this.fitMatches(matches, walked.truncated);
|
|
4790
5202
|
}
|
|
4791
5203
|
/** Apply the `output` limits to a finished match list. */
|
|
4792
|
-
async fitMatches(matches) {
|
|
5204
|
+
async fitMatches(matches, maxFilesReached) {
|
|
5205
|
+
const result = await this.fitMatchesWithin(matches);
|
|
5206
|
+
return maxFilesReached ? {
|
|
5207
|
+
...result,
|
|
5208
|
+
truncated: true,
|
|
5209
|
+
maxFilesReached: true
|
|
5210
|
+
} : result;
|
|
5211
|
+
}
|
|
5212
|
+
async fitMatchesWithin(matches) {
|
|
4793
5213
|
const fitted = await this.spiller.list("grep", matches, (match) => `${match.path}:${match.line}:${match.text}`, "head");
|
|
4794
5214
|
return fitted.truncation === void 0 ? {
|
|
4795
5215
|
matches,
|
|
@@ -4802,27 +5222,42 @@ var Ops = class {
|
|
|
4802
5222
|
...spilledField({ stdout: fitted.truncation })
|
|
4803
5223
|
};
|
|
4804
5224
|
}
|
|
4805
|
-
async globResult(pattern) {
|
|
4806
|
-
const all = await this.
|
|
5225
|
+
async globResult(pattern, opts) {
|
|
5226
|
+
const { paths: all, maxFilesReached } = await this.globWalk(pattern, opts?.signal);
|
|
4807
5227
|
const fitted = await this.spiller.list("glob", all, (entry) => entry, "head");
|
|
4808
|
-
|
|
5228
|
+
const reached = maxFilesReached ? {
|
|
5229
|
+
truncated: true,
|
|
5230
|
+
maxFilesReached: true
|
|
5231
|
+
} : {};
|
|
5232
|
+
return fitted.truncation === void 0 ? {
|
|
5233
|
+
paths: all,
|
|
5234
|
+
...reached
|
|
5235
|
+
} : {
|
|
4809
5236
|
paths: fitted.items,
|
|
4810
5237
|
truncated: true,
|
|
4811
5238
|
truncation: fitted.truncation,
|
|
4812
|
-
...spilledField({ stdout: fitted.truncation })
|
|
5239
|
+
...spilledField({ stdout: fitted.truncation }),
|
|
5240
|
+
...reached
|
|
4813
5241
|
};
|
|
4814
5242
|
}
|
|
4815
|
-
async glob(pattern) {
|
|
5243
|
+
async glob(pattern, opts) {
|
|
5244
|
+
return (await this.globWalk(pattern, opts?.signal)).paths;
|
|
5245
|
+
}
|
|
5246
|
+
/** Match `pattern` against the walked files; sorted, with whether the walk hit `maxFiles`. */
|
|
5247
|
+
async globWalk(pattern, signal) {
|
|
4816
5248
|
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
4817
5249
|
const regex = globToRegExp(pattern);
|
|
4818
|
-
const
|
|
5250
|
+
const walked = await this.searchFiles(jailRoot, pattern, signal);
|
|
4819
5251
|
const matched = [];
|
|
4820
|
-
for (const absFile of files) {
|
|
5252
|
+
for (const absFile of walked.files) {
|
|
4821
5253
|
const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
|
|
4822
5254
|
if (this.isDenied(relativePath)) continue;
|
|
4823
5255
|
if (regex.test(relativePath)) matched.push(relativePath);
|
|
4824
5256
|
}
|
|
4825
|
-
return
|
|
5257
|
+
return {
|
|
5258
|
+
paths: matched.sort(),
|
|
5259
|
+
maxFilesReached: walked.truncated
|
|
5260
|
+
};
|
|
4826
5261
|
}
|
|
4827
5262
|
async exists(inputPath) {
|
|
4828
5263
|
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
@@ -5112,6 +5547,13 @@ var LocalBackend = class {
|
|
|
5112
5547
|
async list(absDir) {
|
|
5113
5548
|
return _warlock_js_fs.fs.dirs.list(absDir);
|
|
5114
5549
|
}
|
|
5550
|
+
/** List children with their kinds from one directory read (symlinks not followed). */
|
|
5551
|
+
async listTyped(absDir) {
|
|
5552
|
+
return (await (0, node_fs_promises.readdir)(absDir, { withFileTypes: true })).map((entry) => ({
|
|
5553
|
+
path: node_path.default.join(absDir, entry.name),
|
|
5554
|
+
type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other"
|
|
5555
|
+
}));
|
|
5556
|
+
}
|
|
5115
5557
|
/**
|
|
5116
5558
|
* Describe a path without following a final symlink (`lstat` — like
|
|
5117
5559
|
* `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
|
|
@@ -5212,6 +5654,10 @@ var LocalBackend = class {
|
|
|
5212
5654
|
settled = true;
|
|
5213
5655
|
if (timer !== void 0) clearTimeout(timer);
|
|
5214
5656
|
signal?.removeEventListener("abort", onAbort);
|
|
5657
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
5658
|
+
const tail = liveDecoders[stream].end();
|
|
5659
|
+
if (tail !== "") opts.onOutput?.(stream, tail);
|
|
5660
|
+
}
|
|
5215
5661
|
resolve({
|
|
5216
5662
|
exitCode,
|
|
5217
5663
|
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
@@ -5220,11 +5666,22 @@ var LocalBackend = class {
|
|
|
5220
5666
|
cancelled
|
|
5221
5667
|
});
|
|
5222
5668
|
};
|
|
5669
|
+
const liveDecoders = {
|
|
5670
|
+
stdout: new node_string_decoder.StringDecoder("utf8"),
|
|
5671
|
+
stderr: new node_string_decoder.StringDecoder("utf8")
|
|
5672
|
+
};
|
|
5673
|
+
const live = (stream, chunk) => {
|
|
5674
|
+
if (opts.onOutput === void 0 || settled) return;
|
|
5675
|
+
const text = liveDecoders[stream].write(chunk);
|
|
5676
|
+
if (text !== "") opts.onOutput(stream, text);
|
|
5677
|
+
};
|
|
5223
5678
|
child.stdout?.on("data", (chunk) => {
|
|
5224
5679
|
stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);
|
|
5680
|
+
live("stdout", chunk);
|
|
5225
5681
|
});
|
|
5226
5682
|
child.stderr?.on("data", (chunk) => {
|
|
5227
5683
|
stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);
|
|
5684
|
+
live("stderr", chunk);
|
|
5228
5685
|
});
|
|
5229
5686
|
child.on("error", () => {
|
|
5230
5687
|
settle(127);
|
|
@@ -5970,12 +6427,13 @@ const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEM
|
|
|
5970
6427
|
function makeGlobTool(ops, options) {
|
|
5971
6428
|
return (0, _warlock_js_ai.tool)({
|
|
5972
6429
|
name: options?.name ?? "glob",
|
|
5973
|
-
description: "Find files in the workspace whose path matches a glob pattern (supports `*`, `**`, and `?`). Returns the matching workspace-relative paths, sorted.",
|
|
6430
|
+
description: "Find files in the workspace whose path matches a glob pattern (supports `*`, `**`, and `?`). Returns the matching workspace-relative paths, sorted. Skips node_modules, .git and gitignored folders; start the pattern with a folder (`src/**/*.ts`) to search faster.",
|
|
5974
6431
|
action: (input) => `Finding files matching ${input.pattern}`,
|
|
5975
6432
|
input: globInputSchema,
|
|
5976
|
-
async execute(input) {
|
|
5977
|
-
|
|
5978
|
-
|
|
6433
|
+
async execute(input, ctx) {
|
|
6434
|
+
const opts = ctx?.signal ? { signal: ctx.signal } : void 0;
|
|
6435
|
+
if (ops.globResult !== void 0) return ops.globResult(input.pattern, opts);
|
|
6436
|
+
return { paths: await ops.glob(input.pattern, opts) };
|
|
5979
6437
|
}
|
|
5980
6438
|
});
|
|
5981
6439
|
}
|
|
@@ -6017,10 +6475,11 @@ function makeGrepTool(ops, options) {
|
|
|
6017
6475
|
description: "Search file contents across the workspace for a regular-expression pattern. Optionally narrow the scanned files with a glob and match case-insensitively. Returns every matching line with its file path and 1-based line number.",
|
|
6018
6476
|
action: (input) => `Searching for /${input.pattern}/`,
|
|
6019
6477
|
input: grepInputSchema,
|
|
6020
|
-
async execute(input) {
|
|
6478
|
+
async execute(input, ctx) {
|
|
6021
6479
|
return ops.grep(input.pattern, {
|
|
6022
6480
|
glob: input.glob,
|
|
6023
|
-
ignoreCase: input.ignoreCase
|
|
6481
|
+
ignoreCase: input.ignoreCase,
|
|
6482
|
+
...ctx?.signal ? { signal: ctx.signal } : {}
|
|
6024
6483
|
});
|
|
6025
6484
|
}
|
|
6026
6485
|
});
|
|
@@ -6276,6 +6735,7 @@ const DIALECT_RULES = {
|
|
|
6276
6735
|
powershell: "Use PowerShell syntax: `;`, `&&`, `||`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not.",
|
|
6277
6736
|
powershell5: "Use Windows PowerShell 5.1 syntax: `;`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). This version cannot chain with `&&` or `||`: put one command per call, or join with `;` (which runs the next command whatever the exit code). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not supported."
|
|
6278
6737
|
};
|
|
6738
|
+
const WINDOWS_BASH_PATH_RULE = " Write paths with forward slashes (`cd ab/terra4/client`, `C:/work/app`): bash reads an unquoted backslash as an escape, so a line with `ab\\terra4` is refused.";
|
|
6279
6739
|
const PLATFORM_NAMES = {
|
|
6280
6740
|
win32: "Windows",
|
|
6281
6741
|
darwin: "macOS",
|
|
@@ -6286,7 +6746,7 @@ function shellSentence(options) {
|
|
|
6286
6746
|
const dialect = options?.shellDialect;
|
|
6287
6747
|
if (options?.shellLabel === void 0 || dialect === void 0 || dialect === "none") return "";
|
|
6288
6748
|
const platform = options.platform ?? process.platform;
|
|
6289
|
-
return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect];
|
|
6749
|
+
return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect] + (platform === "win32" && options.shellLabel === "bash" ? WINDOWS_BASH_PATH_RULE : "");
|
|
6290
6750
|
}
|
|
6291
6751
|
/** The lower of the model's timeout and the cap (either may be absent). */
|
|
6292
6752
|
function cappedTimeout(requested, cap) {
|
|
@@ -6924,8 +7384,8 @@ var WorkspaceImpl = class WorkspaceImpl {
|
|
|
6924
7384
|
grep(pattern, opts) {
|
|
6925
7385
|
return this.ops.grep(pattern, opts);
|
|
6926
7386
|
}
|
|
6927
|
-
glob(pattern) {
|
|
6928
|
-
return this.ops.glob(pattern);
|
|
7387
|
+
glob(pattern, opts) {
|
|
7388
|
+
return this.ops.glob(pattern, opts);
|
|
6929
7389
|
}
|
|
6930
7390
|
exists(filePath) {
|
|
6931
7391
|
return this.ops.exists(filePath);
|