@warlock.js/ai-workspace 5.29.0 → 5.31.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 +68 -0
- package/README.md +40 -21
- package/cjs/index.cjs +3295 -360
- 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 +199 -31
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +12 -6
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +45 -3
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- package/esm/contracts/tool-io.type.d.mts +248 -11
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +78 -2
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +41 -16
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +142 -14
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +75 -22
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +80 -6
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +23 -2
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +13 -5
- package/esm/index.mjs +10 -2
- package/esm/ops.d.mts +22 -3
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +341 -82
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/jail.mjs +73 -0
- package/esm/policy/jail.mjs.map +1 -0
- package/esm/policy/policy.d.mts +26 -16
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +62 -86
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +64 -0
- package/esm/policy/shell-check.d.mts.map +1 -0
- package/esm/policy/shell-check.mjs +627 -0
- package/esm/policy/shell-check.mjs.map +1 -0
- package/esm/policy/shell-parser.mjs +802 -0
- package/esm/policy/shell-parser.mjs.map +1 -0
- package/esm/policy/tokenize-command.d.mts +63 -0
- package/esm/policy/tokenize-command.d.mts.map +1 -0
- package/esm/policy/tokenize-command.mjs +76 -24
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/shell-jobs.d.mts +45 -0
- package/esm/shell-jobs.d.mts.map +1 -0
- package/esm/shell-jobs.mjs +247 -0
- package/esm/shell-jobs.mjs.map +1 -0
- package/esm/todos.d.mts +22 -0
- package/esm/todos.d.mts.map +1 -0
- package/esm/todos.mjs +73 -0
- package/esm/todos.mjs.map +1 -0
- 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 +3 -2
- 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 +3 -2
- 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 +3 -2
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/json-schemas.mjs +175 -0
- package/esm/tools/json-schemas.mjs.map +1 -0
- package/esm/tools/list-dir.d.mts +32 -0
- package/esm/tools/list-dir.d.mts.map +1 -0
- package/esm/tools/list-dir.mjs +50 -0
- package/esm/tools/list-dir.mjs.map +1 -0
- package/esm/tools/multi-edit.d.mts +38 -0
- package/esm/tools/multi-edit.d.mts.map +1 -0
- package/esm/tools/multi-edit.mjs +121 -0
- package/esm/tools/multi-edit.mjs.map +1 -0
- 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 +10 -20
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +39 -13
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +60 -47
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +11 -2
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +15 -7
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +24 -19
- package/esm/tools/schema.mjs.map +1 -1
- package/esm/tools/shell-kill.d.mts +25 -0
- package/esm/tools/shell-kill.d.mts.map +1 -0
- package/esm/tools/shell-kill.mjs +36 -0
- package/esm/tools/shell-kill.mjs.map +1 -0
- package/esm/tools/shell-output.d.mts +30 -0
- package/esm/tools/shell-output.d.mts.map +1 -0
- package/esm/tools/shell-output.mjs +49 -0
- package/esm/tools/shell-output.mjs.map +1 -0
- package/esm/tools/todo.d.mts +43 -0
- package/esm/tools/todo.d.mts.map +1 -0
- package/esm/tools/todo.mjs +126 -0
- package/esm/tools/todo.mjs.map +1 -0
- 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 +3 -2
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +12 -9
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +109 -28
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +192 -35
- package/llms.txt +1 -1
- package/package.json +5 -4
- package/skills/README.md +2 -2
- package/skills/build-loop-agent/SKILL.md +7 -7
- package/skills/index.md +6 -1
- package/skills/use-a-workspace/SKILL.md +185 -28
package/esm/ops.mjs
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
import { WorkspaceEditError, WorkspacePolicyError } from "./errors.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { globToRegExp, isInside, matchesAnyGlob } from "./policy/jail.mjs";
|
|
3
|
+
import { checkShellCommand } from "./policy/shell-check.mjs";
|
|
4
|
+
import { buildCommandEnv, resolveInJail } from "./policy/policy.mjs";
|
|
5
|
+
import { ShellJobs } from "./shell-jobs.mjs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import { fs } from "@warlock.js/fs";
|
|
5
8
|
|
|
6
|
-
//#region
|
|
9
|
+
//#region ../../packages/ai-workspace/src/ops.ts
|
|
7
10
|
/** Default line window a read returns when the policy sets no `defaultLines`. */
|
|
8
11
|
const DEFAULT_READ_LINES = 2e3;
|
|
9
12
|
/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
|
|
10
13
|
const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
11
14
|
/** Default per-command output byte cap when the policy sets none. */
|
|
12
15
|
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
16
|
+
/** Default `list.ignore` when the policy sets none. */
|
|
17
|
+
const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
|
|
18
|
+
/** Default deepest `depth` a listing may ask for. */
|
|
19
|
+
const DEFAULT_LIST_MAX_DEPTH = 5;
|
|
20
|
+
/** Default most entries one listing returns. */
|
|
21
|
+
const DEFAULT_LIST_MAX_ENTRIES = 500;
|
|
22
|
+
/** Most edits one `multiEdit` call may carry. */
|
|
23
|
+
const MAX_MULTI_EDITS = 100;
|
|
24
|
+
/**
|
|
25
|
+
* Exit code reported for a command refused because its signal was already
|
|
26
|
+
* aborted (the shell convention for an interrupted command). Callers should
|
|
27
|
+
* branch on `cancelled`, not on this number.
|
|
28
|
+
*/
|
|
29
|
+
const CANCELLED_BEFORE_START_EXIT_CODE = 130;
|
|
13
30
|
/**
|
|
14
31
|
* Hard ceiling on `grep` pattern length. A model-controlled regex has no
|
|
15
32
|
* legitimate reason to be this long; longer patterns are rejected outright
|
|
@@ -24,57 +41,74 @@ const MAX_GREP_PATTERN_LENGTH = 200;
|
|
|
24
41
|
* this are skipped rather than scanned.
|
|
25
42
|
*/
|
|
26
43
|
const MAX_GREP_LINE_SCAN_LENGTH = 2e3;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* number of the first line in the window.
|
|
31
|
-
*/
|
|
32
|
-
function numberLines(content, startLine) {
|
|
33
|
-
return content.split("\n").map((line, index) => {
|
|
34
|
-
const lineNumber = startLine + index;
|
|
35
|
-
return `${String(lineNumber).padStart(6, " ")}\t${line}`;
|
|
36
|
-
}).join("\n");
|
|
44
|
+
/** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */
|
|
45
|
+
function linePrefix(lineNumber) {
|
|
46
|
+
return `${String(lineNumber).padStart(6, " ")}\t`;
|
|
37
47
|
}
|
|
38
48
|
/**
|
|
39
|
-
* Clip
|
|
40
|
-
*
|
|
49
|
+
* Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a
|
|
50
|
+
* character: the cut backs off over continuation bytes (`10xxxxxx`) to the
|
|
51
|
+
* start of the character it would have split, so the result never carries
|
|
52
|
+
* a replacement character.
|
|
41
53
|
*/
|
|
42
|
-
function
|
|
54
|
+
function clipUtf8(value, maxBytes) {
|
|
43
55
|
const bytes = Buffer.from(value, "utf8");
|
|
44
56
|
if (bytes.byteLength <= maxBytes) return {
|
|
45
57
|
value,
|
|
46
58
|
truncated: false
|
|
47
59
|
};
|
|
60
|
+
let cut = Math.max(0, maxBytes);
|
|
61
|
+
while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
|
|
48
62
|
return {
|
|
49
|
-
value: bytes.subarray(0,
|
|
63
|
+
value: bytes.subarray(0, cut).toString("utf8"),
|
|
50
64
|
truncated: true
|
|
51
65
|
};
|
|
52
66
|
}
|
|
53
67
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
68
|
+
* Number a window of lines `cat -n` style, keeping the joined output within
|
|
69
|
+
* `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.
|
|
70
|
+
* A line that does not fit whole is clipped on a character boundary; it is
|
|
71
|
+
* still counted as returned when its prefix and at least one character fit.
|
|
58
72
|
*/
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else source += "[^/]*";
|
|
73
|
+
function numberWindow(lines, startLine, maxBytes) {
|
|
74
|
+
const parts = [];
|
|
75
|
+
let used = 0;
|
|
76
|
+
for (let index = 0; index < lines.length; index++) {
|
|
77
|
+
const prefix = linePrefix(startLine + index);
|
|
78
|
+
const numbered = prefix + lines[index];
|
|
79
|
+
const separator = parts.length > 0 ? 1 : 0;
|
|
80
|
+
if (maxBytes === void 0) {
|
|
81
|
+
parts.push(numbered);
|
|
69
82
|
continue;
|
|
70
83
|
}
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
const cost = separator + Buffer.byteLength(numbered, "utf8");
|
|
85
|
+
if (used + cost <= maxBytes) {
|
|
86
|
+
parts.push(numbered);
|
|
87
|
+
used += cost;
|
|
73
88
|
continue;
|
|
74
89
|
}
|
|
75
|
-
|
|
90
|
+
const room = maxBytes - used - separator - Buffer.byteLength(prefix, "utf8");
|
|
91
|
+
const clipped = room > 0 ? clipUtf8(lines[index] ?? "", room).value : "";
|
|
92
|
+
if (clipped.length > 0) parts.push(prefix + clipped);
|
|
93
|
+
return {
|
|
94
|
+
content: parts.join("\n"),
|
|
95
|
+
returnedLines: parts.length,
|
|
96
|
+
bytesCapped: true
|
|
97
|
+
};
|
|
76
98
|
}
|
|
77
|
-
return
|
|
99
|
+
return {
|
|
100
|
+
content: parts.join("\n"),
|
|
101
|
+
returnedLines: parts.length,
|
|
102
|
+
bytesCapped: false
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a
|
|
107
|
+
* character boundary. Returns the possibly-clipped string plus whether
|
|
108
|
+
* clipping occurred.
|
|
109
|
+
*/
|
|
110
|
+
function capOutput(value, maxBytes) {
|
|
111
|
+
return clipUtf8(value, maxBytes);
|
|
78
112
|
}
|
|
79
113
|
/**
|
|
80
114
|
* A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
|
|
@@ -98,17 +132,29 @@ function isSafeGrepPattern(pattern) {
|
|
|
98
132
|
if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
|
|
99
133
|
return !NESTED_QUANTIFIER_PATTERN.test(pattern);
|
|
100
134
|
}
|
|
101
|
-
/**
|
|
102
|
-
* The internal, single-instance implementation of {@link WorkspaceOps}.
|
|
103
|
-
* Holds the backend + policy and is the one place the jail, command
|
|
104
|
-
* gating, read caps, and the read-before-edit guard are enforced — both
|
|
105
|
-
* the agent-facing tools and the human-facing direct methods funnel
|
|
106
|
-
* through this object, so there is exactly one set of rules.
|
|
107
|
-
*/
|
|
108
135
|
var Ops = class {
|
|
109
|
-
constructor(backend, policy) {
|
|
136
|
+
constructor(backend, policy, runtime) {
|
|
110
137
|
this.backend = backend;
|
|
111
138
|
this.policy = policy;
|
|
139
|
+
this.runtime = runtime;
|
|
140
|
+
this.pathLocks = /* @__PURE__ */ new Map();
|
|
141
|
+
}
|
|
142
|
+
/** Run `task` after every earlier mutation of `absolutePath` has settled. */
|
|
143
|
+
async withPathLock(absolutePath, task) {
|
|
144
|
+
const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();
|
|
145
|
+
let release = () => {};
|
|
146
|
+
const current = new Promise((resolve) => {
|
|
147
|
+
release = resolve;
|
|
148
|
+
});
|
|
149
|
+
const tail = previous.then(() => current);
|
|
150
|
+
this.pathLocks.set(absolutePath, tail);
|
|
151
|
+
try {
|
|
152
|
+
await previous;
|
|
153
|
+
return await task();
|
|
154
|
+
} finally {
|
|
155
|
+
release();
|
|
156
|
+
if (this.pathLocks.get(absolutePath) === tail) this.pathLocks.delete(absolutePath);
|
|
157
|
+
}
|
|
112
158
|
}
|
|
113
159
|
/**
|
|
114
160
|
* Recursively collect every file under `absDir` as absolute paths,
|
|
@@ -143,25 +189,39 @@ var Ops = class {
|
|
|
143
189
|
const totalLines = lines.length;
|
|
144
190
|
const offset = Math.max(1, opts?.offset ?? 1);
|
|
145
191
|
const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
|
|
192
|
+
const maxBytes = this.policy.read?.maxBytes;
|
|
146
193
|
const startIndex = offset - 1;
|
|
194
|
+
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)));
|
|
195
|
+
const endLine = offset + returnedLines - 1;
|
|
147
196
|
return {
|
|
148
|
-
content
|
|
149
|
-
|
|
150
|
-
|
|
197
|
+
content,
|
|
198
|
+
startLine: offset,
|
|
199
|
+
endLine,
|
|
200
|
+
totalLines,
|
|
201
|
+
truncated: bytesCapped || endLine < totalLines,
|
|
202
|
+
bytesCapped,
|
|
203
|
+
totalBytes: Buffer.byteLength(raw, "utf8"),
|
|
204
|
+
hash
|
|
151
205
|
};
|
|
152
206
|
}
|
|
153
207
|
async writeFile(inputPath, content) {
|
|
154
208
|
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
return this.withPathLock(absolutePath, async () => {
|
|
210
|
+
const parent = path.dirname(absolutePath);
|
|
211
|
+
await this.backend.mkdir(parent);
|
|
212
|
+
await this.backend.writeFile(absolutePath, content);
|
|
213
|
+
return {
|
|
214
|
+
hash: fs.hash.string(content),
|
|
215
|
+
bytesWritten: Buffer.byteLength(content, "utf8")
|
|
216
|
+
};
|
|
217
|
+
});
|
|
162
218
|
}
|
|
163
219
|
async editFile(input) {
|
|
164
220
|
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
221
|
+
return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
|
|
222
|
+
}
|
|
223
|
+
/** The read-check-replace-write of {@link editFile}, run under its path lock. */
|
|
224
|
+
async applyEdit(input, absolutePath, relativePath) {
|
|
165
225
|
const current = await this.backend.readFile(absolutePath);
|
|
166
226
|
const currentHash = fs.hash.string(current);
|
|
167
227
|
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
|
|
@@ -170,48 +230,222 @@ var Ops = class {
|
|
|
170
230
|
expectedHash: input.expectHash,
|
|
171
231
|
actualHash: currentHash
|
|
172
232
|
});
|
|
173
|
-
const
|
|
174
|
-
if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
|
|
175
|
-
type: "not-found",
|
|
176
|
-
path: relativePath || input.path,
|
|
177
|
-
matches: 0
|
|
178
|
-
});
|
|
179
|
-
if (occurrences > 1 && !input.replaceAll) throw new WorkspaceEditError(`The text to replace is not unique in "${input.path}" (${occurrences} matches); pass replaceAll or include more surrounding context.`, {
|
|
180
|
-
type: "not-unique",
|
|
181
|
-
path: relativePath || input.path,
|
|
182
|
-
matches: occurrences
|
|
183
|
-
});
|
|
184
|
-
const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
|
|
233
|
+
const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
|
|
185
234
|
await this.backend.writeFile(absolutePath, updated);
|
|
186
235
|
return {
|
|
187
236
|
path: relativePath || input.path,
|
|
188
|
-
replacements
|
|
237
|
+
replacements,
|
|
189
238
|
hash: fs.hash.string(updated)
|
|
190
239
|
};
|
|
191
240
|
}
|
|
192
|
-
async
|
|
193
|
-
|
|
241
|
+
async multiEdit(input) {
|
|
242
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
243
|
+
const shownPath = relativePath || input.path;
|
|
244
|
+
if (input.edits.length === 0 || input.edits.length > 100) throw new RangeError(`A multi-edit needs between 1 and ${100} edits (got ${input.edits.length}).`);
|
|
245
|
+
return this.withPathLock(absolutePath, async () => {
|
|
246
|
+
const current = await this.backend.readFile(absolutePath);
|
|
247
|
+
const currentHash = fs.hash.string(current);
|
|
248
|
+
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; none of the edits were applied.`, {
|
|
249
|
+
type: "stale-hash",
|
|
250
|
+
path: shownPath,
|
|
251
|
+
expectedHash: input.expectHash,
|
|
252
|
+
actualHash: currentHash
|
|
253
|
+
});
|
|
254
|
+
let content = current;
|
|
255
|
+
let replacements = 0;
|
|
256
|
+
input.edits.forEach((edit, editIndex) => {
|
|
257
|
+
const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
|
|
258
|
+
content = applied.content;
|
|
259
|
+
replacements += applied.replacements;
|
|
260
|
+
});
|
|
261
|
+
await this.backend.writeFile(absolutePath, content);
|
|
262
|
+
return {
|
|
263
|
+
path: shownPath,
|
|
264
|
+
edits: input.edits.length,
|
|
265
|
+
replacements,
|
|
266
|
+
hash: fs.hash.string(content)
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check a command line against the shell policy: every command in it
|
|
272
|
+
* (argv or shell mode), then — shell mode — every redirection target and
|
|
273
|
+
* `cd` directory against the jail with symlinks resolved.
|
|
274
|
+
*/
|
|
275
|
+
async assertCommandAllowed(command) {
|
|
276
|
+
const verdict = checkShellCommand(this.policy, command);
|
|
277
|
+
if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
|
|
194
278
|
type: "denied-command",
|
|
279
|
+
command,
|
|
280
|
+
...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
|
|
281
|
+
...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
|
|
282
|
+
...verdict.path !== void 0 ? { path: verdict.path } : {}
|
|
283
|
+
});
|
|
284
|
+
for (const target of verdict.paths) try {
|
|
285
|
+
await resolveInJail(this.policy, target.path);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!(error instanceof WorkspacePolicyError)) throw error;
|
|
288
|
+
throw new WorkspacePolicyError(target.access === "cwd" ? `\`cd ${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.` : `The redirection target \`${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.`, {
|
|
289
|
+
type: "denied-command",
|
|
290
|
+
reason: "path-escape",
|
|
291
|
+
command,
|
|
292
|
+
path: target.target
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
|
|
296
|
+
type: "denied-command",
|
|
297
|
+
reason: "not-allowed",
|
|
195
298
|
command
|
|
196
299
|
});
|
|
300
|
+
}
|
|
301
|
+
async exec(command, opts) {
|
|
302
|
+
await this.assertCommandAllowed(command);
|
|
303
|
+
const signal = opts?.signal;
|
|
304
|
+
if (signal?.aborted) return {
|
|
305
|
+
exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
|
|
306
|
+
stdout: "",
|
|
307
|
+
stderr: "Command was not executed: the invocation was cancelled before it started.",
|
|
308
|
+
truncated: false,
|
|
309
|
+
timedOut: false,
|
|
310
|
+
cancelled: true
|
|
311
|
+
};
|
|
197
312
|
const shell = this.policy.shell;
|
|
198
313
|
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
199
314
|
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
315
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
200
316
|
const result = await this.backend.exec(command, {
|
|
201
317
|
cwd: this.policy.cwd,
|
|
202
318
|
timeoutMs,
|
|
203
|
-
env:
|
|
319
|
+
env: buildCommandEnv(this.policy),
|
|
320
|
+
...signal ? { signal } : {},
|
|
321
|
+
...interpreter !== "none" ? { interpreter } : {}
|
|
204
322
|
});
|
|
205
323
|
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
206
324
|
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
325
|
+
const cancelled = result.cancelled === true;
|
|
207
326
|
return {
|
|
208
327
|
exitCode: result.exitCode,
|
|
209
328
|
stdout: stdout.value,
|
|
210
329
|
stderr: stderr.value,
|
|
211
330
|
truncated: stdout.truncated || stderr.truncated,
|
|
212
|
-
timedOut: result.timedOut
|
|
331
|
+
timedOut: result.timedOut && !cancelled,
|
|
332
|
+
cancelled
|
|
213
333
|
};
|
|
214
334
|
}
|
|
335
|
+
async startShell(command, opts) {
|
|
336
|
+
const background = this.policy.shell?.background;
|
|
337
|
+
if (background === void 0) throw new WorkspacePolicyError("Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.", {
|
|
338
|
+
type: "denied-command",
|
|
339
|
+
reason: "not-allowed",
|
|
340
|
+
command
|
|
341
|
+
});
|
|
342
|
+
await this.assertCommandAllowed(command);
|
|
343
|
+
const start = this.backend.start?.bind(this.backend);
|
|
344
|
+
if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
|
|
345
|
+
type: "denied-command",
|
|
346
|
+
reason: "not-allowed",
|
|
347
|
+
command
|
|
348
|
+
});
|
|
349
|
+
const shell = this.policy.shell;
|
|
350
|
+
const interpreter = shell?.interpreter ?? "none";
|
|
351
|
+
const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
|
|
352
|
+
const env = buildCommandEnv(this.policy);
|
|
353
|
+
return this.runtime.jobs.start({
|
|
354
|
+
command,
|
|
355
|
+
retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
356
|
+
maxJobs: background.maxJobs ?? 4,
|
|
357
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
358
|
+
launch: (onOutput) => start(command, {
|
|
359
|
+
cwd: this.policy.cwd,
|
|
360
|
+
env,
|
|
361
|
+
...interpreter !== "none" ? { interpreter } : {},
|
|
362
|
+
onOutput
|
|
363
|
+
})
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
shellOutput(id, opts) {
|
|
367
|
+
return this.runtime.jobs.output(id, opts);
|
|
368
|
+
}
|
|
369
|
+
shellKill(id) {
|
|
370
|
+
return this.runtime.jobs.kill(id);
|
|
371
|
+
}
|
|
372
|
+
listShells() {
|
|
373
|
+
return this.runtime.jobs.list();
|
|
374
|
+
}
|
|
375
|
+
dispose() {
|
|
376
|
+
return this.runtime.jobs.dispose();
|
|
377
|
+
}
|
|
378
|
+
async listDir(inputPath = ".", opts) {
|
|
379
|
+
const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
|
|
380
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
381
|
+
const listPolicy = this.policy.list;
|
|
382
|
+
const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
|
|
383
|
+
const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
|
|
384
|
+
const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
|
|
385
|
+
const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
|
|
386
|
+
const insideCwd = isInside(root, jailRoot);
|
|
387
|
+
if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
|
|
388
|
+
const entries = [];
|
|
389
|
+
let level = [root];
|
|
390
|
+
let truncated = false;
|
|
391
|
+
for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
|
|
392
|
+
const nextLevel = [];
|
|
393
|
+
for (const directory of level) {
|
|
394
|
+
let children;
|
|
395
|
+
try {
|
|
396
|
+
children = await this.backend.list(directory);
|
|
397
|
+
} catch {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
401
|
+
for (const child of children) {
|
|
402
|
+
const relativeToJail = insideCwd ? path.relative(jailRoot, child).split(path.sep).join("/") : child;
|
|
403
|
+
if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
|
|
404
|
+
const relativeToRoot = path.relative(root, child).split(path.sep).join("/");
|
|
405
|
+
if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
|
|
406
|
+
if (entries.length >= maxEntries) {
|
|
407
|
+
truncated = true;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
const described = await this.describe(child);
|
|
411
|
+
entries.push({
|
|
412
|
+
path: relativeToJail,
|
|
413
|
+
type: described.type,
|
|
414
|
+
...described.size !== void 0 ? { size: described.size } : {},
|
|
415
|
+
depth: currentDepth
|
|
416
|
+
});
|
|
417
|
+
if (described.type === "directory") nextLevel.push(child);
|
|
418
|
+
}
|
|
419
|
+
if (truncated) break;
|
|
420
|
+
}
|
|
421
|
+
if (truncated) break;
|
|
422
|
+
level = nextLevel;
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
path: insideCwd ? rootRelative || "." : root,
|
|
426
|
+
entries,
|
|
427
|
+
truncated
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Kind and size of a listed entry; symlinks are reported, never followed. */
|
|
431
|
+
async describe(absPath) {
|
|
432
|
+
if (this.backend.stat) try {
|
|
433
|
+
const stats = await this.backend.stat(absPath);
|
|
434
|
+
return stats.type === "file" ? {
|
|
435
|
+
type: "file",
|
|
436
|
+
size: stats.size
|
|
437
|
+
} : { type: stats.type };
|
|
438
|
+
} catch {
|
|
439
|
+
return { type: "other" };
|
|
440
|
+
}
|
|
441
|
+
if (await this.isDirectory(absPath)) try {
|
|
442
|
+
await resolveInJail(this.policy, absPath);
|
|
443
|
+
return { type: "directory" };
|
|
444
|
+
} catch {
|
|
445
|
+
return { type: "other" };
|
|
446
|
+
}
|
|
447
|
+
return { type: "file" };
|
|
448
|
+
}
|
|
215
449
|
async grep(pattern, opts) {
|
|
216
450
|
if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
|
|
217
451
|
type: "unsafe-pattern",
|
|
@@ -235,7 +469,7 @@ var Ops = class {
|
|
|
235
469
|
}
|
|
236
470
|
const lines = content.split("\n");
|
|
237
471
|
for (let index = 0; index < lines.length; index++) {
|
|
238
|
-
const line = lines[index];
|
|
472
|
+
const line = lines[index] ?? "";
|
|
239
473
|
if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
|
|
240
474
|
if (regex.test(line)) {
|
|
241
475
|
matches.push({
|
|
@@ -277,22 +511,45 @@ var Ops = class {
|
|
|
277
511
|
}
|
|
278
512
|
async remove(inputPath) {
|
|
279
513
|
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
280
|
-
await this.backend.remove(absolutePath);
|
|
514
|
+
await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));
|
|
281
515
|
}
|
|
282
516
|
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
283
517
|
isDenied(relativePath) {
|
|
284
518
|
const denyPaths = this.policy.denyPaths;
|
|
285
|
-
|
|
286
|
-
return denyPaths.some((glob) => {
|
|
287
|
-
if (globToRegExp(glob).test(relativePath)) return true;
|
|
288
|
-
if (!glob.includes("*") && !glob.includes("?")) {
|
|
289
|
-
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
290
|
-
return relativePath.startsWith(prefix);
|
|
291
|
-
}
|
|
292
|
-
return false;
|
|
293
|
-
});
|
|
519
|
+
return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
|
|
294
520
|
}
|
|
295
521
|
};
|
|
522
|
+
/** The lower of two optional limits (either may be absent). */
|
|
523
|
+
function lowerOf(policyLimit, requested) {
|
|
524
|
+
if (requested === void 0 || requested <= 0) return policyLimit;
|
|
525
|
+
return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Apply one exact-string replacement to `content` under the edit rules:
|
|
529
|
+
* `oldString` must occur, and exactly once unless `replaceAll`.
|
|
530
|
+
*/
|
|
531
|
+
function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
|
|
532
|
+
const occurrences = countOccurrences(content, edit.oldString);
|
|
533
|
+
const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
|
|
534
|
+
const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
|
|
535
|
+
const indexOption = editIndex === void 0 ? {} : { editIndex };
|
|
536
|
+
if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
|
|
537
|
+
type: "not-found",
|
|
538
|
+
path: shownPath,
|
|
539
|
+
matches: 0,
|
|
540
|
+
...indexOption
|
|
541
|
+
});
|
|
542
|
+
if (occurrences > 1 && !edit.replaceAll) throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${occurrences} matches); pass replaceAll or include more surrounding context.${nothingApplied}`, {
|
|
543
|
+
type: "not-unique",
|
|
544
|
+
path: shownPath,
|
|
545
|
+
matches: occurrences,
|
|
546
|
+
...indexOption
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
|
|
550
|
+
replacements: edit.replaceAll ? occurrences : 1
|
|
551
|
+
};
|
|
552
|
+
}
|
|
296
553
|
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
297
554
|
function countOccurrences(haystack, needle) {
|
|
298
555
|
if (needle === "") return 0;
|
|
@@ -324,13 +581,15 @@ function replaceFirst(haystack, needle, replacement) {
|
|
|
324
581
|
*
|
|
325
582
|
* @param backend - The dumb IO executor (local disk or in-memory mock).
|
|
326
583
|
* @param policy - The policy that bounds every operation.
|
|
584
|
+
* @param runtime - State shared with other views of the same workspace
|
|
585
|
+
* (its background commands); a fresh one when omitted.
|
|
327
586
|
*
|
|
328
587
|
* @example
|
|
329
588
|
* const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
|
|
330
589
|
* const { content, hash } = await ops.readFile("src/index.ts");
|
|
331
590
|
*/
|
|
332
|
-
function createOps(backend, policy) {
|
|
333
|
-
return new Ops(backend, policy);
|
|
591
|
+
function createOps(backend, policy, runtime) {
|
|
592
|
+
return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
|
|
334
593
|
}
|
|
335
594
|
|
|
336
595
|
//#endregion
|