@warlock.js/ai-workspace 4.5.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/cjs/index.cjs +1609 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/backends/local.d.mts +22 -0
- package/esm/backends/local.d.mts.map +1 -0
- package/esm/backends/local.mjs +208 -0
- package/esm/backends/local.mjs.map +1 -0
- package/esm/backends/mock.d.mts +62 -0
- package/esm/backends/mock.d.mts.map +1 -0
- package/esm/backends/mock.mjs +167 -0
- package/esm/backends/mock.mjs.map +1 -0
- package/esm/contracts/index.d.mts +5 -0
- package/esm/contracts/tool-io.type.d.mts +149 -0
- package/esm/contracts/tool-io.type.d.mts.map +1 -0
- package/esm/contracts/workspace-backend.contract.d.mts +69 -0
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -0
- package/esm/contracts/workspace-ops.contract.d.mts +72 -0
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -0
- package/esm/contracts/workspace-policy.type.d.mts +86 -0
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -0
- package/esm/contracts/workspace.contract.d.mts +131 -0
- package/esm/contracts/workspace.contract.d.mts.map +1 -0
- package/esm/errors.d.mts +100 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +58 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/index.d.mts +20 -0
- package/esm/index.mjs +15 -0
- package/esm/ops.d.mts +25 -0
- package/esm/ops.d.mts.map +1 -0
- package/esm/ops.mjs +294 -0
- package/esm/ops.mjs.map +1 -0
- package/esm/policy/policy.d.mts +71 -0
- package/esm/policy/policy.d.mts.map +1 -0
- package/esm/policy/policy.mjs +184 -0
- package/esm/policy/policy.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts +40 -0
- package/esm/tools/edit-file.d.mts.map +1 -0
- package/esm/tools/edit-file.mjs +57 -0
- package/esm/tools/edit-file.mjs.map +1 -0
- package/esm/tools/glob.d.mts +37 -0
- package/esm/tools/glob.d.mts.map +1 -0
- package/esm/tools/glob.mjs +45 -0
- package/esm/tools/glob.mjs.map +1 -0
- package/esm/tools/grep.d.mts +36 -0
- package/esm/tools/grep.d.mts.map +1 -0
- package/esm/tools/grep.mjs +51 -0
- package/esm/tools/grep.mjs.map +1 -0
- package/esm/tools/read-file.d.mts +35 -0
- package/esm/tools/read-file.d.mts.map +1 -0
- package/esm/tools/read-file.mjs +64 -0
- package/esm/tools/read-file.mjs.map +1 -0
- package/esm/tools/run-shell.d.mts +35 -0
- package/esm/tools/run-shell.d.mts.map +1 -0
- package/esm/tools/run-shell.mjs +65 -0
- package/esm/tools/run-shell.mjs.map +1 -0
- package/esm/tools/run-tests.d.mts +40 -0
- package/esm/tools/run-tests.d.mts.map +1 -0
- package/esm/tools/run-tests.mjs +67 -0
- package/esm/tools/run-tests.mjs.map +1 -0
- package/esm/tools/schema.mjs +111 -0
- package/esm/tools/schema.mjs.map +1 -0
- package/esm/tools/write-file.d.mts +33 -0
- package/esm/tools/write-file.d.mts.map +1 -0
- package/esm/tools/write-file.mjs +52 -0
- package/esm/tools/write-file.mjs.map +1 -0
- package/esm/workspace.d.mts +54 -0
- package/esm/workspace.d.mts.map +1 -0
- package/esm/workspace.mjs +210 -0
- package/esm/workspace.mjs.map +1 -0
- package/llms-full.txt +231 -0
- package/llms.txt +10 -0
- package/package.json +42 -0
- package/skills/README.md +13 -0
- package/skills/build-loop-agent/SKILL.md +100 -0
- package/skills/use-a-workspace/SKILL.md +117 -0
package/cjs/index.cjs
ADDED
|
@@ -0,0 +1,1609 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: ((k) => from[k]).bind(null, key),
|
|
16
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
24
|
+
value: mod,
|
|
25
|
+
enumerable: true
|
|
26
|
+
}) : target, mod));
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
let _warlock_js_ai = require("@warlock.js/ai");
|
|
30
|
+
let node_path = require("node:path");
|
|
31
|
+
node_path = __toESM(node_path, 1);
|
|
32
|
+
let node_fs_promises = require("node:fs/promises");
|
|
33
|
+
let _warlock_js_fs = require("@warlock.js/fs");
|
|
34
|
+
let node_child_process = require("node:child_process");
|
|
35
|
+
let node_process = require("node:process");
|
|
36
|
+
|
|
37
|
+
//#region ../@warlock.js/ai-workspace/src/errors.ts
|
|
38
|
+
/**
|
|
39
|
+
* The workspace policy engine refused an operation — a path escaped the
|
|
40
|
+
* jail (or hit a deny glob), or a shell command's executable was not
|
|
41
|
+
* allowed.
|
|
42
|
+
*
|
|
43
|
+
* **Surface.** This is returned to the agent as tool-error *data*, never
|
|
44
|
+
* a thrown run-killer — the agent reads the failure and self-corrects.
|
|
45
|
+
* Extends the framework `AIError` (category `"tool"`, code
|
|
46
|
+
* `TOOL_EXEC_FAILED`) so it flows through the same typed error contract
|
|
47
|
+
* as every other AI error; branch on `error.type` for the specific
|
|
48
|
+
* violation.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
|
|
52
|
+
* console.warn(`Blocked command: ${error.command}`);
|
|
53
|
+
* }
|
|
54
|
+
*/
|
|
55
|
+
var WorkspacePolicyError = class extends _warlock_js_ai.AIError {
|
|
56
|
+
constructor(message, options) {
|
|
57
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
58
|
+
this.name = "WorkspacePolicyError";
|
|
59
|
+
this.type = options.type;
|
|
60
|
+
this.path = options.path;
|
|
61
|
+
this.command = options.command;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* An `editFile` operation was rejected by the read-before-edit guard:
|
|
66
|
+
* the `oldString` was absent, matched non-uniquely without `replaceAll`,
|
|
67
|
+
* or the file's hash no longer matched the supplied `expectHash`.
|
|
68
|
+
*
|
|
69
|
+
* **Surface.** Like {@link WorkspacePolicyError}, returned to the agent
|
|
70
|
+
* as tool-error *data* so it can re-read and retry. Extends `AIError`
|
|
71
|
+
* (category `"tool"`, code `TOOL_EXEC_FAILED`); branch on `error.type`.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* if (error instanceof WorkspaceEditError && error.type === "stale-hash") {
|
|
75
|
+
* // re-read the file and retry the edit with the fresh hash
|
|
76
|
+
* }
|
|
77
|
+
*/
|
|
78
|
+
var WorkspaceEditError = class extends _warlock_js_ai.AIError {
|
|
79
|
+
constructor(message, options) {
|
|
80
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
81
|
+
this.name = "WorkspaceEditError";
|
|
82
|
+
this.type = options.type;
|
|
83
|
+
this.path = options.path;
|
|
84
|
+
this.matches = options.matches;
|
|
85
|
+
this.expectedHash = options.expectedHash;
|
|
86
|
+
this.actualHash = options.actualHash;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region ../@warlock.js/ai-workspace/src/policy/policy.ts
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the canonical absolute form of `target`, collapsing any
|
|
94
|
+
* symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),
|
|
95
|
+
* so we realpath the deepest **existing** ancestor and re-attach the
|
|
96
|
+
* non-existent tail — a symlinked ancestor still cannot smuggle the
|
|
97
|
+
* path out of the jail, while genuinely new leaves stay creatable.
|
|
98
|
+
*/
|
|
99
|
+
async function canonicalize$1(target) {
|
|
100
|
+
let resolvedTarget = node_path.default.resolve(target);
|
|
101
|
+
const tail = [];
|
|
102
|
+
while (true) try {
|
|
103
|
+
const real = await (0, node_fs_promises.realpath)(resolvedTarget);
|
|
104
|
+
return tail.length > 0 ? node_path.default.join(real, ...tail) : real;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error.code !== "ENOENT") throw error;
|
|
107
|
+
const parent = node_path.default.dirname(resolvedTarget);
|
|
108
|
+
if (parent === resolvedTarget) return node_path.default.join(resolvedTarget, ...tail);
|
|
109
|
+
tail.unshift(node_path.default.basename(resolvedTarget));
|
|
110
|
+
resolvedTarget = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether `child` is contained within `root` (or equals it), comparing
|
|
115
|
+
* canonical absolute paths. Guards against the `/srv/app-evil` vs
|
|
116
|
+
* `/srv/app` prefix-collision by anchoring on a path separator.
|
|
117
|
+
*/
|
|
118
|
+
function isInside(child, root) {
|
|
119
|
+
const relative = node_path.default.relative(root, child);
|
|
120
|
+
return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
|
|
124
|
+
* `?`) into an anchored `RegExp` over a `/`-separated relative path.
|
|
125
|
+
* `**` spans path separators; a single `*` does not.
|
|
126
|
+
*/
|
|
127
|
+
function globToRegExp$1(glob) {
|
|
128
|
+
let source = "";
|
|
129
|
+
for (let index = 0; index < glob.length; index++) {
|
|
130
|
+
const char = glob[index];
|
|
131
|
+
if (char === "*") {
|
|
132
|
+
if (glob[index + 1] === "*") {
|
|
133
|
+
source += ".*";
|
|
134
|
+
index++;
|
|
135
|
+
if (glob[index + 1] === "/") index++;
|
|
136
|
+
} else source += "[^/]*";
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char === "?") {
|
|
140
|
+
source += "[^/]";
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
144
|
+
}
|
|
145
|
+
return new RegExp(`^${source}$`);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
149
|
+
* policy's `denyPaths` globs. A deny glob also blocks everything beneath
|
|
150
|
+
* a matched directory (`".git/**"` blocks `.git/config`).
|
|
151
|
+
*/
|
|
152
|
+
function matchesDeny(relativePath, denyPaths) {
|
|
153
|
+
return denyPaths.some((glob) => {
|
|
154
|
+
if (globToRegExp$1(glob).test(relativePath)) return true;
|
|
155
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
156
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
157
|
+
return relativePath.startsWith(prefix);
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve and jail a single input path against a {@link WorkspacePolicy}.
|
|
164
|
+
*
|
|
165
|
+
* The input is resolved against `policy.cwd`, canonicalized (symlinks in
|
|
166
|
+
* existing ancestors collapsed so a symlinked directory cannot escape
|
|
167
|
+
* the jail), then accepted **only** when it sits under `cwd` or one of
|
|
168
|
+
* the `allowPaths` roots. A path that escapes, or that matches any
|
|
169
|
+
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
170
|
+
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
171
|
+
*
|
|
172
|
+
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
173
|
+
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
174
|
+
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
175
|
+
* @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* const { absolutePath } = await resolveInJail(policy, "src/index.ts");
|
|
179
|
+
*/
|
|
180
|
+
async function resolveInJail(policy, inputPath) {
|
|
181
|
+
const jailRoot = await canonicalize$1(policy.cwd);
|
|
182
|
+
const absolutePath = await canonicalize$1(node_path.default.isAbsolute(inputPath) ? inputPath : node_path.default.join(policy.cwd, inputPath));
|
|
183
|
+
const insideCwd = isInside(absolutePath, jailRoot);
|
|
184
|
+
const allowRoots = policy.allowPaths ?? [];
|
|
185
|
+
let insideAllow = false;
|
|
186
|
+
if (!insideCwd) {
|
|
187
|
+
for (const root of allowRoots) if (isInside(absolutePath, await canonicalize$1(root))) {
|
|
188
|
+
insideAllow = true;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
|
|
193
|
+
type: "path-escape",
|
|
194
|
+
path: inputPath
|
|
195
|
+
});
|
|
196
|
+
const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
|
|
197
|
+
if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
|
|
198
|
+
if (matchesDeny(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
|
|
199
|
+
type: "path-escape",
|
|
200
|
+
path: inputPath
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
absolutePath,
|
|
205
|
+
relativePath: relativeToJail
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Extract the leading executable basename from a command line — the
|
|
210
|
+
* token the shell allow/deny policy is keyed on. `"npm run build"` →
|
|
211
|
+
* `"npm"`; `"/usr/bin/node app.js"` → `"node"`; `"node.exe app"` →
|
|
212
|
+
* `"node"` (the `.exe`/`.cmd`/`.bat` Windows extension is stripped).
|
|
213
|
+
*/
|
|
214
|
+
function leadingExecutable(command) {
|
|
215
|
+
const firstToken = command.trim().split(/\s+/)[0] ?? "";
|
|
216
|
+
return node_path.default.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Whether a shell command is permitted by the policy's `shell` sub-policy.
|
|
220
|
+
*
|
|
221
|
+
* The command's leading executable basename is matched against
|
|
222
|
+
* `shell.deny` then `shell.allow`. **Deny always wins.** When
|
|
223
|
+
* `shell.allow` is set, the executable MUST appear in it (fail-closed
|
|
224
|
+
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
225
|
+
* permitted. An absent `shell` block means no command may run at all.
|
|
226
|
+
*
|
|
227
|
+
* Returns a plain `boolean` rather than throwing — the ops layer raises
|
|
228
|
+
* the {@link WorkspacePolicyError} so the thrown context (`command`)
|
|
229
|
+
* lives next to the call site.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
233
|
+
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
|
|
234
|
+
*/
|
|
235
|
+
function isCommandAllowed(policy, command) {
|
|
236
|
+
const shell = policy.shell;
|
|
237
|
+
if (!shell) return false;
|
|
238
|
+
const executable = leadingExecutable(command);
|
|
239
|
+
if (executable === "") return false;
|
|
240
|
+
if (shell.deny && shell.deny.includes(executable)) return false;
|
|
241
|
+
if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Build the exact environment a spawned process receives — `process.env`
|
|
246
|
+
* is **never** inherited wholesale. The result is
|
|
247
|
+
* `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command
|
|
248
|
+
* cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the
|
|
249
|
+
* policy opts in via `shell.inheritEnv: ["PATH"]`. Explicit `shell.env`
|
|
250
|
+
* values override inherited ones on key collision.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* buildEnv({ cwd, shell: { inheritEnv: ["PATH"], env: { CI: "1" } } });
|
|
254
|
+
* // → { PATH: <process PATH>, CI: "1" }
|
|
255
|
+
*/
|
|
256
|
+
function buildEnv(policy) {
|
|
257
|
+
const shell = policy.shell;
|
|
258
|
+
const env = {};
|
|
259
|
+
if (!shell) return env;
|
|
260
|
+
for (const key of shell.inheritEnv ?? []) {
|
|
261
|
+
const value = process.env[key];
|
|
262
|
+
if (value !== void 0) env[key] = value;
|
|
263
|
+
}
|
|
264
|
+
if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
|
|
265
|
+
return env;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region ../@warlock.js/ai-workspace/src/ops.ts
|
|
270
|
+
/** Default line window a read returns when the policy sets no `defaultLines`. */
|
|
271
|
+
const DEFAULT_READ_LINES = 2e3;
|
|
272
|
+
/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
|
|
273
|
+
const DEFAULT_MAX_GREP_MATCHES = 1e3;
|
|
274
|
+
/** Default per-command output byte cap when the policy sets none. */
|
|
275
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
|
|
276
|
+
/**
|
|
277
|
+
* Number the lines of `content` `cat -n` style: a right-aligned line
|
|
278
|
+
* number (min width 6), a tab, then the line. `startLine` is the 1-based
|
|
279
|
+
* number of the first line in the window.
|
|
280
|
+
*/
|
|
281
|
+
function numberLines(content, startLine) {
|
|
282
|
+
return content.split("\n").map((line, index) => {
|
|
283
|
+
const lineNumber = startLine + index;
|
|
284
|
+
return `${String(lineNumber).padStart(6, " ")}\t${line}`;
|
|
285
|
+
}).join("\n");
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).
|
|
289
|
+
* Returns the possibly-clipped string plus whether clipping occurred.
|
|
290
|
+
*/
|
|
291
|
+
function capOutput(value, maxBytes) {
|
|
292
|
+
const bytes = Buffer.from(value, "utf8");
|
|
293
|
+
if (bytes.byteLength <= maxBytes) return {
|
|
294
|
+
value,
|
|
295
|
+
truncated: false
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
value: bytes.subarray(0, maxBytes).toString("utf8"),
|
|
299
|
+
truncated: true
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Minimal glob match over a `/`-separated relative path. Supports `**`
|
|
304
|
+
* (spans separators), `*` (within a segment), and `?` (one non-separator
|
|
305
|
+
* char) — enough for the workspace's `glob`/grep narrowing without
|
|
306
|
+
* pulling in a runtime dependency.
|
|
307
|
+
*/
|
|
308
|
+
function globToRegExp(glob) {
|
|
309
|
+
let source = "";
|
|
310
|
+
for (let index = 0; index < glob.length; index++) {
|
|
311
|
+
const char = glob[index];
|
|
312
|
+
if (char === "*") {
|
|
313
|
+
if (glob[index + 1] === "*") {
|
|
314
|
+
source += ".*";
|
|
315
|
+
index++;
|
|
316
|
+
if (glob[index + 1] === "/") index++;
|
|
317
|
+
} else source += "[^/]*";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (char === "?") {
|
|
321
|
+
source += "[^/]";
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
325
|
+
}
|
|
326
|
+
return new RegExp(`^${source}$`);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The internal, single-instance implementation of {@link WorkspaceOps}.
|
|
330
|
+
* Holds the backend + policy and is the one place the jail, command
|
|
331
|
+
* gating, read caps, and the read-before-edit guard are enforced — both
|
|
332
|
+
* the agent-facing tools and the human-facing direct methods funnel
|
|
333
|
+
* through this object, so there is exactly one set of rules.
|
|
334
|
+
*/
|
|
335
|
+
var Ops = class {
|
|
336
|
+
constructor(backend, policy) {
|
|
337
|
+
this.backend = backend;
|
|
338
|
+
this.policy = policy;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Recursively collect every file under `absDir` as absolute paths,
|
|
342
|
+
* via the backend's `list` (so it works over disk or the in-memory
|
|
343
|
+
* mock). Directories are descended; files are accumulated.
|
|
344
|
+
*/
|
|
345
|
+
async walkFiles(absDir) {
|
|
346
|
+
const found = [];
|
|
347
|
+
const entries = await this.backend.list(absDir);
|
|
348
|
+
await Promise.all(entries.map(async (entry) => {
|
|
349
|
+
if (await this.isDirectory(entry)) {
|
|
350
|
+
const nested = await this.walkFiles(entry);
|
|
351
|
+
found.push(...nested);
|
|
352
|
+
} else found.push(entry);
|
|
353
|
+
}));
|
|
354
|
+
return found;
|
|
355
|
+
}
|
|
356
|
+
/** Whether an absolute path is a directory, by attempting to list it. */
|
|
357
|
+
async isDirectory(absPath) {
|
|
358
|
+
try {
|
|
359
|
+
await this.backend.list(absPath);
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async readFile(inputPath, opts) {
|
|
366
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
367
|
+
const raw = await this.backend.readFile(absolutePath);
|
|
368
|
+
const hash = (0, _warlock_js_fs.hashString)(raw);
|
|
369
|
+
const lines = raw.split("\n");
|
|
370
|
+
const totalLines = lines.length;
|
|
371
|
+
const offset = Math.max(1, opts?.offset ?? 1);
|
|
372
|
+
const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
|
|
373
|
+
const startIndex = offset - 1;
|
|
374
|
+
return {
|
|
375
|
+
content: numberLines(lines.slice(startIndex, startIndex + limit).join("\n"), offset),
|
|
376
|
+
hash,
|
|
377
|
+
totalLines
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async writeFile(inputPath, content) {
|
|
381
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
382
|
+
const parent = node_path.default.dirname(absolutePath);
|
|
383
|
+
await this.backend.mkdir(parent);
|
|
384
|
+
await this.backend.writeFile(absolutePath, content);
|
|
385
|
+
return {
|
|
386
|
+
hash: (0, _warlock_js_fs.hashString)(content),
|
|
387
|
+
bytesWritten: Buffer.byteLength(content, "utf8")
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async editFile(input) {
|
|
391
|
+
const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
|
|
392
|
+
const current = await this.backend.readFile(absolutePath);
|
|
393
|
+
const currentHash = (0, _warlock_js_fs.hashString)(current);
|
|
394
|
+
if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
|
|
395
|
+
type: "stale-hash",
|
|
396
|
+
path: relativePath || input.path,
|
|
397
|
+
expectedHash: input.expectHash,
|
|
398
|
+
actualHash: currentHash
|
|
399
|
+
});
|
|
400
|
+
const occurrences = countOccurrences(current, input.oldString);
|
|
401
|
+
if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
|
|
402
|
+
type: "not-found",
|
|
403
|
+
path: relativePath || input.path,
|
|
404
|
+
matches: 0
|
|
405
|
+
});
|
|
406
|
+
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.`, {
|
|
407
|
+
type: "not-unique",
|
|
408
|
+
path: relativePath || input.path,
|
|
409
|
+
matches: occurrences
|
|
410
|
+
});
|
|
411
|
+
const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
|
|
412
|
+
await this.backend.writeFile(absolutePath, updated);
|
|
413
|
+
return {
|
|
414
|
+
path: relativePath || input.path,
|
|
415
|
+
replacements: input.replaceAll ? occurrences : 1,
|
|
416
|
+
hash: (0, _warlock_js_fs.hashString)(updated)
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async exec(command, opts) {
|
|
420
|
+
if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
|
|
421
|
+
type: "denied-command",
|
|
422
|
+
command
|
|
423
|
+
});
|
|
424
|
+
const shell = this.policy.shell;
|
|
425
|
+
const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
|
|
426
|
+
const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
427
|
+
const result = await this.backend.exec(command, {
|
|
428
|
+
cwd: this.policy.cwd,
|
|
429
|
+
timeoutMs,
|
|
430
|
+
env: buildEnv(this.policy)
|
|
431
|
+
});
|
|
432
|
+
const stdout = capOutput(result.stdout, maxOutputBytes);
|
|
433
|
+
const stderr = capOutput(result.stderr, maxOutputBytes);
|
|
434
|
+
return {
|
|
435
|
+
exitCode: result.exitCode,
|
|
436
|
+
stdout: stdout.value,
|
|
437
|
+
stderr: stderr.value,
|
|
438
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
439
|
+
timedOut: result.timedOut
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
async grep(pattern, opts) {
|
|
443
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
444
|
+
const flags = opts?.ignoreCase ? "i" : "";
|
|
445
|
+
const regex = new RegExp(pattern, flags);
|
|
446
|
+
const globRegex = opts?.glob ? globToRegExp(opts.glob) : void 0;
|
|
447
|
+
const files = await this.walkFiles(jailRoot);
|
|
448
|
+
const matches = [];
|
|
449
|
+
for (const absFile of files) {
|
|
450
|
+
const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
|
|
451
|
+
if (globRegex && !globRegex.test(relativePath)) continue;
|
|
452
|
+
if (this.isDenied(relativePath)) continue;
|
|
453
|
+
let content;
|
|
454
|
+
try {
|
|
455
|
+
content = await this.backend.readFile(absFile);
|
|
456
|
+
} catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const lines = content.split("\n");
|
|
460
|
+
for (let index = 0; index < lines.length; index++) if (regex.test(lines[index])) {
|
|
461
|
+
matches.push({
|
|
462
|
+
path: relativePath,
|
|
463
|
+
line: index + 1,
|
|
464
|
+
text: lines[index]
|
|
465
|
+
});
|
|
466
|
+
if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
|
|
467
|
+
matches,
|
|
468
|
+
total: matches.length
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
matches,
|
|
474
|
+
total: matches.length
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async glob(pattern) {
|
|
478
|
+
const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
|
|
479
|
+
const regex = globToRegExp(pattern);
|
|
480
|
+
const files = await this.walkFiles(jailRoot);
|
|
481
|
+
const matched = [];
|
|
482
|
+
for (const absFile of files) {
|
|
483
|
+
const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
|
|
484
|
+
if (this.isDenied(relativePath)) continue;
|
|
485
|
+
if (regex.test(relativePath)) matched.push(relativePath);
|
|
486
|
+
}
|
|
487
|
+
return matched.sort();
|
|
488
|
+
}
|
|
489
|
+
async exists(inputPath) {
|
|
490
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
491
|
+
return this.backend.exists(absolutePath);
|
|
492
|
+
}
|
|
493
|
+
async mkdir(inputPath) {
|
|
494
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
495
|
+
await this.backend.mkdir(absolutePath);
|
|
496
|
+
}
|
|
497
|
+
async remove(inputPath) {
|
|
498
|
+
const { absolutePath } = await resolveInJail(this.policy, inputPath);
|
|
499
|
+
await this.backend.remove(absolutePath);
|
|
500
|
+
}
|
|
501
|
+
/** Whether a `/`-separated relative path hits the policy deny list. */
|
|
502
|
+
isDenied(relativePath) {
|
|
503
|
+
const denyPaths = this.policy.denyPaths;
|
|
504
|
+
if (!denyPaths || denyPaths.length === 0) return false;
|
|
505
|
+
return denyPaths.some((glob) => {
|
|
506
|
+
if (globToRegExp(glob).test(relativePath)) return true;
|
|
507
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
508
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
509
|
+
return relativePath.startsWith(prefix);
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
516
|
+
function countOccurrences(haystack, needle) {
|
|
517
|
+
if (needle === "") return 0;
|
|
518
|
+
let count = 0;
|
|
519
|
+
let from = 0;
|
|
520
|
+
while (true) {
|
|
521
|
+
const index = haystack.indexOf(needle, from);
|
|
522
|
+
if (index === -1) break;
|
|
523
|
+
count++;
|
|
524
|
+
from = index + needle.length;
|
|
525
|
+
}
|
|
526
|
+
return count;
|
|
527
|
+
}
|
|
528
|
+
/** Replace the first occurrence of `needle` with `replacement`. */
|
|
529
|
+
function replaceFirst(haystack, needle, replacement) {
|
|
530
|
+
const index = haystack.indexOf(needle);
|
|
531
|
+
if (index === -1) return haystack;
|
|
532
|
+
return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Create the policy-enforced operation layer over a backend.
|
|
536
|
+
*
|
|
537
|
+
* The returned {@link WorkspaceOps} is the single seam both the
|
|
538
|
+
* agent-facing `.tools.*` factories and the human-facing direct methods
|
|
539
|
+
* delegate to — one jail, one command-gate, one read-before-edit guard,
|
|
540
|
+
* regardless of caller. Path inputs are workspace-relative and resolved
|
|
541
|
+
* against `policy.cwd`; escapes and denied commands surface as typed
|
|
542
|
+
* {@link WorkspacePolicyError} / {@link WorkspaceEditError}.
|
|
543
|
+
*
|
|
544
|
+
* @param backend - The dumb IO executor (local disk or in-memory mock).
|
|
545
|
+
* @param policy - The policy that bounds every operation.
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
|
|
549
|
+
* const { content, hash } = await ops.readFile("src/index.ts");
|
|
550
|
+
*/
|
|
551
|
+
function createOps(backend, policy) {
|
|
552
|
+
return new Ops(backend, policy);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region ../@warlock.js/ai-workspace/src/backends/local.ts
|
|
557
|
+
/**
|
|
558
|
+
* Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw
|
|
559
|
+
* executor truncates each stream once this many bytes have accumulated so a
|
|
560
|
+
* runaway command cannot exhaust memory; the ops layer applies its own
|
|
561
|
+
* policy-driven cap (and the `truncated` flag) on top. Generous enough that
|
|
562
|
+
* ordinary command output is never clipped here.
|
|
563
|
+
*/
|
|
564
|
+
const MAX_STREAM_BYTES = 10 * 1024 * 1024;
|
|
565
|
+
/**
|
|
566
|
+
* Append a chunk to a capped list of buffers, tracking the running byte
|
|
567
|
+
* total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes
|
|
568
|
+
* past the cap are dropped rather than buffered.
|
|
569
|
+
*/
|
|
570
|
+
function pushCapped(chunks, total, chunk) {
|
|
571
|
+
if (total >= MAX_STREAM_BYTES) return total;
|
|
572
|
+
const remaining = MAX_STREAM_BYTES - total;
|
|
573
|
+
if (chunk.length <= remaining) {
|
|
574
|
+
chunks.push(chunk);
|
|
575
|
+
return total + chunk.length;
|
|
576
|
+
}
|
|
577
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
578
|
+
return MAX_STREAM_BYTES;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Force-kill a spawned command and its entire process tree.
|
|
582
|
+
*
|
|
583
|
+
* With `shell: true` the command runs under an intermediary shell
|
|
584
|
+
* (`cmd.exe` on Windows, `/bin/sh` elsewhere), so signalling the direct
|
|
585
|
+
* child only reaps the shell — a long-running grandchild (e.g. `node`)
|
|
586
|
+
* would survive, leaving the `exec` promise unsettled. We therefore kill
|
|
587
|
+
* the whole group:
|
|
588
|
+
* - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.
|
|
589
|
+
* - **POSIX** — the child is spawned `detached`, becoming its own process
|
|
590
|
+
* group leader, so `process.kill(-pid)` SIGKILLs the group.
|
|
591
|
+
*/
|
|
592
|
+
function killTree(pid, child) {
|
|
593
|
+
if (pid === void 0) {
|
|
594
|
+
child.kill("SIGKILL");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (node_process.platform === "win32") {
|
|
598
|
+
(0, node_child_process.spawn)("taskkill", [
|
|
599
|
+
"/pid",
|
|
600
|
+
String(pid),
|
|
601
|
+
"/T",
|
|
602
|
+
"/F"
|
|
603
|
+
], { windowsHide: true });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
process.kill(-pid, "SIGKILL");
|
|
608
|
+
} catch {
|
|
609
|
+
child.kill("SIGKILL");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* The real-disk executor: every filesystem method delegates to
|
|
614
|
+
* `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
|
|
615
|
+
* process via `node:child_process`. It is deliberately **policy-agnostic** —
|
|
616
|
+
* it receives already-resolved absolute paths and an already-resolved
|
|
617
|
+
* environment + timeout from the ops layer, and just performs the side
|
|
618
|
+
* effect. See {@link WorkspaceBackend} for the contract this implements.
|
|
619
|
+
*
|
|
620
|
+
* Constructed via {@link createLocalBackend}; the class itself is internal.
|
|
621
|
+
*/
|
|
622
|
+
var LocalBackend = class {
|
|
623
|
+
/** Read a file's full UTF-8 content at an absolute path. */
|
|
624
|
+
async readFile(absPath) {
|
|
625
|
+
return (0, _warlock_js_fs.getFileAsync)(absPath);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Write full content to an absolute path. Uses `atomicWriteAsync`, so a
|
|
629
|
+
* concurrent reader never observes a half-written file, and missing parent
|
|
630
|
+
* directories are created.
|
|
631
|
+
*/
|
|
632
|
+
async writeFile(absPath, content) {
|
|
633
|
+
await (0, _warlock_js_fs.atomicWriteAsync)(absPath, content);
|
|
634
|
+
}
|
|
635
|
+
/** Whether anything (file or directory) exists at an absolute path. */
|
|
636
|
+
async exists(absPath) {
|
|
637
|
+
return (0, _warlock_js_fs.pathExistsAsync)(absPath);
|
|
638
|
+
}
|
|
639
|
+
/** Create a directory (and any missing parents) at an absolute path; idempotent. */
|
|
640
|
+
async mkdir(absPath) {
|
|
641
|
+
await (0, _warlock_js_fs.ensureDirectoryAsync)(absPath);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Remove a file or directory tree at an absolute path. Stats the target to
|
|
645
|
+
* pick the right primitive — `removeDirectoryAsync` (recursive) for a
|
|
646
|
+
* directory, `unlinkAsync` for anything else. A path that does not exist is
|
|
647
|
+
* a no-op (both primitives swallow `ENOENT`).
|
|
648
|
+
*/
|
|
649
|
+
async remove(absPath) {
|
|
650
|
+
let isDirectory = false;
|
|
651
|
+
try {
|
|
652
|
+
isDirectory = (await (0, _warlock_js_fs.statsAsync)(absPath)).isDirectory();
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (error?.code === "ENOENT") return;
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
if (isDirectory) {
|
|
658
|
+
await (0, _warlock_js_fs.removeDirectoryAsync)(absPath);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
await (0, _warlock_js_fs.unlinkAsync)(absPath);
|
|
662
|
+
}
|
|
663
|
+
/** List immediate children of an absolute directory as absolute paths. */
|
|
664
|
+
async list(absDir) {
|
|
665
|
+
return (0, _warlock_js_fs.listAsync)(absDir);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Resolve symlinks and `..` segments to a canonical absolute path — the
|
|
669
|
+
* primitive the ops-layer jail uses to detect escapes. Delegates to
|
|
670
|
+
* `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
|
|
671
|
+
* does not expose.
|
|
672
|
+
*/
|
|
673
|
+
async realpath(absPath) {
|
|
674
|
+
return (0, node_fs_promises.realpath)(absPath);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Run a command and capture its outcome. The command line is executed
|
|
678
|
+
* through the platform shell (`shell: true`) so pipes/operators behave as a
|
|
679
|
+
* caller would expect; `cwd`, `env`, and the timeout are taken verbatim from
|
|
680
|
+
* the ops layer (the environment is NOT merged with `process.env`). On
|
|
681
|
+
* timeout the process is SIGKILLed and `timedOut` is set. `stdout`/`stderr`
|
|
682
|
+
* are captured and byte-capped per {@link MAX_STREAM_BYTES}.
|
|
683
|
+
*
|
|
684
|
+
* Never rejects for a non-zero exit, a missing executable, or a timeout —
|
|
685
|
+
* those are reported through the resolved {@link WorkspaceBackendExecResult}
|
|
686
|
+
* so the ops layer can surface them as tool-error data.
|
|
687
|
+
*/
|
|
688
|
+
exec(command, opts = {}) {
|
|
689
|
+
return new Promise((resolve) => {
|
|
690
|
+
const child = (0, node_child_process.spawn)(command, {
|
|
691
|
+
cwd: opts.cwd,
|
|
692
|
+
env: opts.env,
|
|
693
|
+
shell: true,
|
|
694
|
+
windowsHide: true,
|
|
695
|
+
detached: node_process.platform !== "win32"
|
|
696
|
+
});
|
|
697
|
+
const stdoutChunks = [];
|
|
698
|
+
const stderrChunks = [];
|
|
699
|
+
let stdoutBytes = 0;
|
|
700
|
+
let stderrBytes = 0;
|
|
701
|
+
let timedOut = false;
|
|
702
|
+
let settled = false;
|
|
703
|
+
const timer = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? setTimeout(() => {
|
|
704
|
+
timedOut = true;
|
|
705
|
+
killTree(child.pid, child);
|
|
706
|
+
}, opts.timeoutMs) : void 0;
|
|
707
|
+
const settle = (exitCode) => {
|
|
708
|
+
if (settled) return;
|
|
709
|
+
settled = true;
|
|
710
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
711
|
+
resolve({
|
|
712
|
+
exitCode,
|
|
713
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
714
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
715
|
+
timedOut
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
child.stdout?.on("data", (chunk) => {
|
|
719
|
+
stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);
|
|
720
|
+
});
|
|
721
|
+
child.stderr?.on("data", (chunk) => {
|
|
722
|
+
stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);
|
|
723
|
+
});
|
|
724
|
+
child.on("error", () => {
|
|
725
|
+
settle(127);
|
|
726
|
+
});
|
|
727
|
+
child.on("close", (code, signal) => {
|
|
728
|
+
if (code === null) {
|
|
729
|
+
settle(128 + (signal === "SIGKILL" ? 9 : 1));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
settle(code);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* Create the **local** workspace backend — the default executor that runs the
|
|
739
|
+
* workspace over the real disk (`@warlock.js/fs`) and the local shell
|
|
740
|
+
* (`node:child_process`).
|
|
741
|
+
*
|
|
742
|
+
* The returned object is policy-agnostic: it expects already-jail-resolved
|
|
743
|
+
* absolute paths and an already-resolved environment/timeout from the ops
|
|
744
|
+
* layer. Pair it with {@link WorkspaceOps} for the actual cwd jail,
|
|
745
|
+
* allow/deny lists, hashing, and output policy.
|
|
746
|
+
*
|
|
747
|
+
* @example
|
|
748
|
+
* const backend = createLocalBackend();
|
|
749
|
+
* await backend.writeFile("/srv/app/src/index.ts", "export const x = 1;");
|
|
750
|
+
* const { exitCode } = await backend.exec("node -v", { cwd: "/srv/app" });
|
|
751
|
+
*/
|
|
752
|
+
function createLocalBackend() {
|
|
753
|
+
return new LocalBackend();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region ../@warlock.js/ai-workspace/src/backends/mock.ts
|
|
758
|
+
/**
|
|
759
|
+
* Normalize an absolute path to a stable in-memory key: forward slashes,
|
|
760
|
+
* collapsed duplicate separators, and resolved `.` / `..` segments. There
|
|
761
|
+
* are no symlinks in memory, so this is a pure lexical canonicalization —
|
|
762
|
+
* exactly what the backend's `realpath` promises.
|
|
763
|
+
*/
|
|
764
|
+
function canonicalize(absPath) {
|
|
765
|
+
const unified = absPath.replace(/\\/g, "/");
|
|
766
|
+
const driveMatch = unified.match(/^([a-zA-Z]:)?\/?/);
|
|
767
|
+
const prefix = driveMatch ? driveMatch[0] : "";
|
|
768
|
+
const rest = unified.slice(prefix.length);
|
|
769
|
+
const resolved = [];
|
|
770
|
+
for (const segment of rest.split("/")) {
|
|
771
|
+
if (segment === "" || segment === ".") continue;
|
|
772
|
+
if (segment === "..") {
|
|
773
|
+
resolved.pop();
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
resolved.push(segment);
|
|
777
|
+
}
|
|
778
|
+
const joined = resolved.join("/");
|
|
779
|
+
const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
780
|
+
return joined.length > 0 ? `${normalizedPrefix}${joined}` : normalizedPrefix;
|
|
781
|
+
}
|
|
782
|
+
/** All ancestor directory keys of a canonical path, root-first. */
|
|
783
|
+
function ancestorsOf(canonicalPath) {
|
|
784
|
+
const lastSlash = canonicalPath.lastIndexOf("/");
|
|
785
|
+
if (lastSlash <= 0) return [];
|
|
786
|
+
const parent = canonicalPath.slice(0, lastSlash);
|
|
787
|
+
const result = ancestorsOf(parent);
|
|
788
|
+
result.push(parent);
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* The in-memory executor behind {@link createMockBackend}. Holds the file
|
|
793
|
+
* tree in a `Map`, the directory set in a `Set`, and the scripted command
|
|
794
|
+
* table in a second `Map` — no disk, no child processes, fully
|
|
795
|
+
* deterministic. Construct it via the factory, never directly.
|
|
796
|
+
*/
|
|
797
|
+
var MockBackend = class {
|
|
798
|
+
constructor(seed) {
|
|
799
|
+
this.files = /* @__PURE__ */ new Map();
|
|
800
|
+
this.directories = /* @__PURE__ */ new Set();
|
|
801
|
+
this.commands = /* @__PURE__ */ new Map();
|
|
802
|
+
for (const [path, content] of Object.entries(seed?.files ?? {})) {
|
|
803
|
+
const canonical = canonicalize(path);
|
|
804
|
+
this.files.set(canonical, content);
|
|
805
|
+
this.registerAncestorDirectories(canonical);
|
|
806
|
+
}
|
|
807
|
+
for (const [command, result] of Object.entries(seed?.commands ?? {})) this.commands.set(command, result);
|
|
808
|
+
}
|
|
809
|
+
/** Record every ancestor directory of a path as existing. */
|
|
810
|
+
registerAncestorDirectories(canonicalPath) {
|
|
811
|
+
for (const ancestor of ancestorsOf(canonicalPath)) this.directories.add(ancestor);
|
|
812
|
+
}
|
|
813
|
+
async readFile(absPath) {
|
|
814
|
+
const canonical = canonicalize(absPath);
|
|
815
|
+
const content = this.files.get(canonical);
|
|
816
|
+
if (content === void 0) throw new Error(`Mock backend: no such file: ${canonical}`);
|
|
817
|
+
return content;
|
|
818
|
+
}
|
|
819
|
+
async writeFile(absPath, content) {
|
|
820
|
+
const canonical = canonicalize(absPath);
|
|
821
|
+
this.files.set(canonical, content);
|
|
822
|
+
this.registerAncestorDirectories(canonical);
|
|
823
|
+
}
|
|
824
|
+
async exists(absPath) {
|
|
825
|
+
const canonical = canonicalize(absPath);
|
|
826
|
+
return this.files.has(canonical) || this.directories.has(canonical);
|
|
827
|
+
}
|
|
828
|
+
async mkdir(absPath) {
|
|
829
|
+
const canonical = canonicalize(absPath);
|
|
830
|
+
this.directories.add(canonical);
|
|
831
|
+
this.registerAncestorDirectories(canonical);
|
|
832
|
+
}
|
|
833
|
+
async remove(absPath) {
|
|
834
|
+
const canonical = canonicalize(absPath);
|
|
835
|
+
const prefix = `${canonical}/`;
|
|
836
|
+
for (const file of [...this.files.keys()]) if (file === canonical || file.startsWith(prefix)) this.files.delete(file);
|
|
837
|
+
for (const directory of [...this.directories]) if (directory === canonical || directory.startsWith(prefix)) this.directories.delete(directory);
|
|
838
|
+
}
|
|
839
|
+
async list(absDir) {
|
|
840
|
+
const canonical = canonicalize(absDir);
|
|
841
|
+
const prefix = canonical === "/" ? "/" : `${canonical}/`;
|
|
842
|
+
const children = /* @__PURE__ */ new Set();
|
|
843
|
+
const collect = (key) => {
|
|
844
|
+
if (!key.startsWith(prefix) || key === canonical) return;
|
|
845
|
+
const remainder = key.slice(prefix.length);
|
|
846
|
+
const nextSlash = remainder.indexOf("/");
|
|
847
|
+
const childName = nextSlash === -1 ? remainder : remainder.slice(0, nextSlash);
|
|
848
|
+
if (childName.length > 0) children.add(`${prefix}${childName}`);
|
|
849
|
+
};
|
|
850
|
+
for (const file of this.files.keys()) collect(file);
|
|
851
|
+
for (const directory of this.directories) collect(directory);
|
|
852
|
+
return [...children].sort();
|
|
853
|
+
}
|
|
854
|
+
async realpath(absPath) {
|
|
855
|
+
return canonicalize(absPath);
|
|
856
|
+
}
|
|
857
|
+
async exec(command, _opts) {
|
|
858
|
+
const scripted = this.commands.get(command);
|
|
859
|
+
return {
|
|
860
|
+
exitCode: scripted?.exitCode ?? 0,
|
|
861
|
+
stdout: scripted?.stdout ?? "",
|
|
862
|
+
stderr: scripted?.stderr ?? "",
|
|
863
|
+
timedOut: scripted?.timedOut ?? false
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
/**
|
|
868
|
+
* Create an in-memory {@link WorkspaceBackend} for fast, disk-free tests.
|
|
869
|
+
*
|
|
870
|
+
* Every IO method operates on a `Map` of `absolutePath -> content` (with a
|
|
871
|
+
* companion directory set), so reads, writes, existence checks, `mkdir`,
|
|
872
|
+
* recursive `remove`, `list`, and `realpath` all run synchronously in
|
|
873
|
+
* memory with no filesystem access. `exec` is **scripted**: a registered
|
|
874
|
+
* `command -> result` table is consulted by exact command line, and any
|
|
875
|
+
* unregistered command resolves to a successful `0`-exit no-op with empty
|
|
876
|
+
* output.
|
|
877
|
+
*
|
|
878
|
+
* `realpath` is a pure lexical canonicalization (forward slashes,
|
|
879
|
+
* collapsed separators, resolved `.`/`..`) — there are no symlinks in
|
|
880
|
+
* memory — which is exactly what the jail resolver expects.
|
|
881
|
+
*
|
|
882
|
+
* The single positional `seed` accepts either the shorthand
|
|
883
|
+
* `Record<string, string>` of file contents (matching the design's
|
|
884
|
+
* `ai.workspace.mock(seed)` signature) or the richer {@link MockBackendSeed}
|
|
885
|
+
* with both `files` and scripted `commands`.
|
|
886
|
+
*
|
|
887
|
+
* @example
|
|
888
|
+
* // Shorthand: seed files only.
|
|
889
|
+
* const backend = createMockBackend({ "/srv/app/src/index.ts": "export const x = 1;" });
|
|
890
|
+
* await backend.readFile("/srv/app/src/index.ts"); // "export const x = 1;"
|
|
891
|
+
*
|
|
892
|
+
* @example
|
|
893
|
+
* // Full seed: files plus a scripted command.
|
|
894
|
+
* const backend = createMockBackend({
|
|
895
|
+
* files: { "/srv/app/package.json": "{}" },
|
|
896
|
+
* commands: { "npm test": { exitCode: 1, stderr: "1 failing" } },
|
|
897
|
+
* });
|
|
898
|
+
* await backend.exec("npm test"); // { exitCode: 1, stderr: "1 failing", ... }
|
|
899
|
+
* await backend.exec("echo hi"); // { exitCode: 0, stdout: "", ... } (no-op)
|
|
900
|
+
*/
|
|
901
|
+
function createMockBackend(seed) {
|
|
902
|
+
return new MockBackend(normalizeSeed(seed));
|
|
903
|
+
}
|
|
904
|
+
/** Coerce the dual-shaped `seed` argument into a {@link MockBackendSeed}. */
|
|
905
|
+
function normalizeSeed(seed) {
|
|
906
|
+
if (seed === void 0) return;
|
|
907
|
+
if (isMockBackendSeed(seed)) return seed;
|
|
908
|
+
return { files: seed };
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Whether `seed` is the structured {@link MockBackendSeed} (has a `files`
|
|
912
|
+
* or `commands` key) rather than the flat `path -> content` shorthand. A
|
|
913
|
+
* plain shorthand map whose only key happens to be named `files` is
|
|
914
|
+
* treated as structured — callers wanting that literal path should use the
|
|
915
|
+
* explicit `{ files: { files: "..." } }` form.
|
|
916
|
+
*/
|
|
917
|
+
function isMockBackendSeed(seed) {
|
|
918
|
+
return typeof seed.files === "object" || typeof seed.commands === "object";
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region ../@warlock.js/ai-workspace/src/tools/schema.ts
|
|
923
|
+
/**
|
|
924
|
+
* Tiny, dependency-free [Standard Schema](https://standardschema.dev)
|
|
925
|
+
* builders for the workspace tools' input validation. The package pins
|
|
926
|
+
* only `@warlock.js/ai` and `@warlock.js/fs` as runtime dependencies, so
|
|
927
|
+
* rather than pull in a schema library we hand-roll the few shapes the
|
|
928
|
+
* file tools need — exactly the pattern `@warlock.js/ai`'s own `tool()`
|
|
929
|
+
* tests use. Each builder returns a `StandardSchemaV1`, which is what
|
|
930
|
+
* `tool({ input })` validates against before calling `execute`.
|
|
931
|
+
*
|
|
932
|
+
* These intentionally cover only the primitive cases the FILE tools
|
|
933
|
+
* require (`string`, `optional string`, `optional number`, `optional
|
|
934
|
+
* boolean`, and an `object` of fields). They are not a general-purpose
|
|
935
|
+
* validator.
|
|
936
|
+
*/
|
|
937
|
+
/** The vendor tag stamped on every issue these builders produce. */
|
|
938
|
+
const VENDOR = "ai-workspace";
|
|
939
|
+
/** Required string field — rejects anything that is not a string. */
|
|
940
|
+
function stringField() {
|
|
941
|
+
return (value, key) => {
|
|
942
|
+
if (typeof value === "string") return { value };
|
|
943
|
+
return { issues: [{
|
|
944
|
+
message: `"${key}" must be a string`,
|
|
945
|
+
path: [key]
|
|
946
|
+
}] };
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Optional string field — accepts `undefined` (the property absent or
|
|
951
|
+
* explicitly undefined) or a string, and rejects every other type.
|
|
952
|
+
*/
|
|
953
|
+
function optionalStringField() {
|
|
954
|
+
return (value, key) => {
|
|
955
|
+
if (value === void 0) return { value: void 0 };
|
|
956
|
+
if (typeof value === "string") return { value };
|
|
957
|
+
return { issues: [{
|
|
958
|
+
message: `"${key}" must be a string when provided`,
|
|
959
|
+
path: [key]
|
|
960
|
+
}] };
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Optional finite-number field — accepts `undefined` or a finite number,
|
|
965
|
+
* rejecting `NaN`/`Infinity` and non-number types.
|
|
966
|
+
*/
|
|
967
|
+
function optionalNumberField() {
|
|
968
|
+
return (value, key) => {
|
|
969
|
+
if (value === void 0) return { value: void 0 };
|
|
970
|
+
if (typeof value === "number" && Number.isFinite(value)) return { value };
|
|
971
|
+
return { issues: [{
|
|
972
|
+
message: `"${key}" must be a finite number when provided`,
|
|
973
|
+
path: [key]
|
|
974
|
+
}] };
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
/** Optional boolean field — accepts `undefined` or a boolean. */
|
|
978
|
+
function optionalBooleanField() {
|
|
979
|
+
return (value, key) => {
|
|
980
|
+
if (value === void 0) return { value: void 0 };
|
|
981
|
+
if (typeof value === "boolean") return { value };
|
|
982
|
+
return { issues: [{
|
|
983
|
+
message: `"${key}" must be a boolean when provided`,
|
|
984
|
+
path: [key]
|
|
985
|
+
}] };
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Build a {@link StandardSchemaV1} for a flat object whose every property
|
|
990
|
+
* is validated by a {@link FieldValidator}. The input must be a non-null
|
|
991
|
+
* object; each declared field is validated and the (possibly coerced)
|
|
992
|
+
* values are collected into the typed result. All field issues are merged
|
|
993
|
+
* so the caller sees every problem at once.
|
|
994
|
+
*
|
|
995
|
+
* `T` is constrained to `object` rather than `Record<string, unknown>` so
|
|
996
|
+
* the tool IO `interface`s (which carry no implicit string index
|
|
997
|
+
* signature) satisfy it directly — only the declared keys in `shape` are
|
|
998
|
+
* ever read, so a string index signature is never required.
|
|
999
|
+
*
|
|
1000
|
+
* @example
|
|
1001
|
+
* const schema = objectSchema<{ path: string; limit?: number }>({
|
|
1002
|
+
* path: stringField(),
|
|
1003
|
+
* limit: optionalNumberField(),
|
|
1004
|
+
* });
|
|
1005
|
+
*/
|
|
1006
|
+
function objectSchema(shape) {
|
|
1007
|
+
return { "~standard": {
|
|
1008
|
+
version: 1,
|
|
1009
|
+
vendor: VENDOR,
|
|
1010
|
+
validate(input) {
|
|
1011
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
|
|
1012
|
+
const source = input;
|
|
1013
|
+
const issues = [];
|
|
1014
|
+
const result = {};
|
|
1015
|
+
for (const key of Object.keys(shape)) {
|
|
1016
|
+
const field = shape[key];
|
|
1017
|
+
const outcome = field(source[key], key);
|
|
1018
|
+
if ("issues" in outcome) {
|
|
1019
|
+
issues.push(...outcome.issues);
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
if (outcome.value !== void 0) result[key] = outcome.value;
|
|
1023
|
+
}
|
|
1024
|
+
if (issues.length > 0) return { issues };
|
|
1025
|
+
return { value: result };
|
|
1026
|
+
}
|
|
1027
|
+
} };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region ../@warlock.js/ai-workspace/src/tools/edit-file.ts
|
|
1032
|
+
/** Default tool name exposed to the LLM. */
|
|
1033
|
+
const DEFAULT_NAME$2 = "edit_file";
|
|
1034
|
+
/** Input schema for the `edit_file` tool. */
|
|
1035
|
+
const inputSchema$2 = objectSchema({
|
|
1036
|
+
path: stringField(),
|
|
1037
|
+
oldString: stringField(),
|
|
1038
|
+
newString: stringField(),
|
|
1039
|
+
replaceAll: optionalBooleanField(),
|
|
1040
|
+
expectHash: optionalStringField()
|
|
1041
|
+
});
|
|
1042
|
+
/**
|
|
1043
|
+
* Build the agent-facing `edit_file` tool over a workspace's policy-
|
|
1044
|
+
* enforced {@link WorkspaceOps}.
|
|
1045
|
+
*
|
|
1046
|
+
* The tool validates `{ path, oldString, newString, replaceAll?,
|
|
1047
|
+
* expectHash? }` against a Standard Schema, then delegates to
|
|
1048
|
+
* `ops.editFile`, which applies the exact-string replacement under the
|
|
1049
|
+
* read-before-edit guard and returns the replacement count plus the
|
|
1050
|
+
* post-edit `hash`.
|
|
1051
|
+
*
|
|
1052
|
+
* **Errors flow as data.** A non-unique `oldString` (without
|
|
1053
|
+
* `replaceAll`), a missing `oldString`, or a stale `expectHash` cause
|
|
1054
|
+
* `ops` to throw a `WorkspaceEditError`; the `tool()` wrapper catches it
|
|
1055
|
+
* and surfaces it in the returned `{ error }` field — `invoke()` never
|
|
1056
|
+
* throws — so the agent can re-read and retry.
|
|
1057
|
+
*
|
|
1058
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
1059
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
1060
|
+
*
|
|
1061
|
+
* @example
|
|
1062
|
+
* const editTool = makeEditFileTool(ops);
|
|
1063
|
+
* const { data, error } = await editTool.invoke({
|
|
1064
|
+
* path: "src/index.ts",
|
|
1065
|
+
* oldString: "const a = 1;",
|
|
1066
|
+
* newString: "const a = 2;",
|
|
1067
|
+
* expectHash,
|
|
1068
|
+
* });
|
|
1069
|
+
* if (error) console.warn(error.message); // e.g. stale-hash → re-read
|
|
1070
|
+
*/
|
|
1071
|
+
function makeEditFileTool(ops, options) {
|
|
1072
|
+
return (0, _warlock_js_ai.tool)({
|
|
1073
|
+
name: options?.name ?? DEFAULT_NAME$2,
|
|
1074
|
+
description: "Replace an exact substring in a workspace file. oldString must match uniquely unless replaceAll is set. Pass expectHash (from read_file) to reject the edit if the file changed since you read it. Returns the number of replacements and the new content hash.",
|
|
1075
|
+
input: inputSchema$2,
|
|
1076
|
+
async execute(input) {
|
|
1077
|
+
return ops.editFile(input);
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region ../@warlock.js/ai-workspace/src/tools/glob.ts
|
|
1084
|
+
/**
|
|
1085
|
+
* Standard Schema for {@link GlobInput} — a single required `pattern`
|
|
1086
|
+
* string. Built on the package's shared, dependency-free schema builders
|
|
1087
|
+
* (no schema library, matching the validator idiom the `@warlock.js/ai`
|
|
1088
|
+
* tool runtime expects).
|
|
1089
|
+
*/
|
|
1090
|
+
const globInputSchema = objectSchema({ pattern: stringField() });
|
|
1091
|
+
/**
|
|
1092
|
+
* Build the agent-facing `glob` tool — resolve a glob pattern to the
|
|
1093
|
+
* matching workspace-relative paths within the jail. The returned
|
|
1094
|
+
* {@link ToolContract} validates the LLM's arguments, delegates to
|
|
1095
|
+
* {@link WorkspaceOps.glob} (which returns a bare sorted `string[]`), and
|
|
1096
|
+
* wraps the result in a {@link GlobResult} so the agent always reads a
|
|
1097
|
+
* stable `{ paths }` envelope. The jail and `denyPaths` filtering are
|
|
1098
|
+
* enforced in the shared ops layer; a policy violation surfaces as typed
|
|
1099
|
+
* tool-error *data* via the runtime's `invoke()` wrapper.
|
|
1100
|
+
*
|
|
1101
|
+
* @param ops - The policy-enforced operation layer to delegate to.
|
|
1102
|
+
* @param options - Optional `{ name }` override for the vended tool name.
|
|
1103
|
+
* @returns A {@link ToolContract} the agent can call as `glob`.
|
|
1104
|
+
*
|
|
1105
|
+
* @example
|
|
1106
|
+
* const glob = makeGlobTool(ops);
|
|
1107
|
+
* const { data } = await glob.invoke({ pattern: "src/models/**\/*.ts" });
|
|
1108
|
+
* console.log(data?.paths);
|
|
1109
|
+
*/
|
|
1110
|
+
function makeGlobTool(ops, options) {
|
|
1111
|
+
return (0, _warlock_js_ai.tool)({
|
|
1112
|
+
name: options?.name ?? "glob",
|
|
1113
|
+
description: "Find files in the workspace whose path matches a glob pattern (supports `*`, `**`, and `?`). Returns the matching workspace-relative paths, sorted.",
|
|
1114
|
+
action: (input) => `Finding files matching ${input.pattern}`,
|
|
1115
|
+
input: globInputSchema,
|
|
1116
|
+
async execute(input) {
|
|
1117
|
+
return { paths: await ops.glob(input.pattern) };
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region ../@warlock.js/ai-workspace/src/tools/grep.ts
|
|
1124
|
+
/**
|
|
1125
|
+
* Standard Schema for {@link GrepInput} — `pattern` is a required string;
|
|
1126
|
+
* `glob` and `ignoreCase` are optional. Built on the package's shared,
|
|
1127
|
+
* dependency-free schema builders (no schema library, matching the
|
|
1128
|
+
* validator idiom the `@warlock.js/ai` tool runtime expects).
|
|
1129
|
+
*/
|
|
1130
|
+
const grepInputSchema = objectSchema({
|
|
1131
|
+
pattern: stringField(),
|
|
1132
|
+
glob: optionalStringField(),
|
|
1133
|
+
ignoreCase: optionalBooleanField()
|
|
1134
|
+
});
|
|
1135
|
+
/**
|
|
1136
|
+
* Build the agent-facing `grep` tool — a regex content search across the
|
|
1137
|
+
* jailed file set. The returned {@link ToolContract} validates the LLM's
|
|
1138
|
+
* arguments, then delegates verbatim to {@link WorkspaceOps.grep}, so the
|
|
1139
|
+
* policy jail, `denyPaths` filtering, and match cap are enforced in the
|
|
1140
|
+
* single shared ops layer rather than duplicated here. A policy violation
|
|
1141
|
+
* (e.g. a jail-resolution failure) surfaces as typed tool-error *data*
|
|
1142
|
+
* via the runtime's `invoke()` wrapper, never as a thrown run-killer.
|
|
1143
|
+
*
|
|
1144
|
+
* @param ops - The policy-enforced operation layer to delegate to.
|
|
1145
|
+
* @param options - Optional `{ name }` override for the vended tool name.
|
|
1146
|
+
* @returns A {@link ToolContract} the agent can call as `grep`.
|
|
1147
|
+
*
|
|
1148
|
+
* @example
|
|
1149
|
+
* const grep = makeGrepTool(ops);
|
|
1150
|
+
* const { data } = await grep.invoke({ pattern: "TODO", glob: "src/*.ts" });
|
|
1151
|
+
* console.log(data?.total, data?.matches);
|
|
1152
|
+
*/
|
|
1153
|
+
function makeGrepTool(ops, options) {
|
|
1154
|
+
return (0, _warlock_js_ai.tool)({
|
|
1155
|
+
name: options?.name ?? "grep",
|
|
1156
|
+
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.",
|
|
1157
|
+
action: (input) => `Searching for /${input.pattern}/`,
|
|
1158
|
+
input: grepInputSchema,
|
|
1159
|
+
async execute(input) {
|
|
1160
|
+
return ops.grep(input.pattern, {
|
|
1161
|
+
glob: input.glob,
|
|
1162
|
+
ignoreCase: input.ignoreCase
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region ../@warlock.js/ai-workspace/src/tools/read-file.ts
|
|
1170
|
+
/** Default tool name exposed to the LLM. */
|
|
1171
|
+
const DEFAULT_NAME$1 = "read_file";
|
|
1172
|
+
/** Input schema for the `read_file` tool. */
|
|
1173
|
+
const inputSchema$1 = objectSchema({
|
|
1174
|
+
path: stringField(),
|
|
1175
|
+
startLine: optionalNumberField(),
|
|
1176
|
+
limit: optionalNumberField()
|
|
1177
|
+
});
|
|
1178
|
+
/**
|
|
1179
|
+
* Build the agent-facing `read_file` tool over a workspace's policy-
|
|
1180
|
+
* enforced {@link WorkspaceOps}.
|
|
1181
|
+
*
|
|
1182
|
+
* The tool validates `{ path, startLine?, limit? }` against a Standard
|
|
1183
|
+
* Schema, then delegates to `ops.readFile`, mapping the result into the
|
|
1184
|
+
* agent wire shape {@link ReadFileResult} — the `hash` an agent must
|
|
1185
|
+
* carry into a later `edit_file` (read-before-edit), plus the `startLine`
|
|
1186
|
+
* / `endLine` / `truncated` window metadata derived from the requested
|
|
1187
|
+
* range and the file's `totalLines`.
|
|
1188
|
+
*
|
|
1189
|
+
* **Errors flow as data.** Policy violations (a jail escape) are thrown
|
|
1190
|
+
* by `ops`; the `tool()` wrapper catches them and surfaces them in the
|
|
1191
|
+
* returned `{ error }` field — `invoke()` never throws — so the agent can
|
|
1192
|
+
* read the failure and self-correct.
|
|
1193
|
+
*
|
|
1194
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
1195
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
1196
|
+
*
|
|
1197
|
+
* @example
|
|
1198
|
+
* const readTool = makeReadFileTool(ops);
|
|
1199
|
+
* const { data, error } = await readTool.invoke({ path: "src/index.ts" });
|
|
1200
|
+
* if (!error) console.log(data.hash); // feed into edit_file's expectHash
|
|
1201
|
+
*/
|
|
1202
|
+
function makeReadFileTool(ops, options) {
|
|
1203
|
+
return (0, _warlock_js_ai.tool)({
|
|
1204
|
+
name: options?.name ?? DEFAULT_NAME$1,
|
|
1205
|
+
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.",
|
|
1206
|
+
input: inputSchema$1,
|
|
1207
|
+
async execute(input) {
|
|
1208
|
+
const startLine = input.startLine !== void 0 ? Math.max(1, input.startLine) : 1;
|
|
1209
|
+
const { content, hash, totalLines } = await ops.readFile(input.path, {
|
|
1210
|
+
offset: startLine,
|
|
1211
|
+
limit: input.limit
|
|
1212
|
+
});
|
|
1213
|
+
const returnedLines = content.length === 0 ? 0 : content.split("\n").length;
|
|
1214
|
+
const endLine = Math.min(totalLines, startLine + Math.max(returnedLines, 1) - 1);
|
|
1215
|
+
return {
|
|
1216
|
+
content,
|
|
1217
|
+
startLine,
|
|
1218
|
+
endLine,
|
|
1219
|
+
totalLines,
|
|
1220
|
+
truncated: endLine < totalLines,
|
|
1221
|
+
hash
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
//#endregion
|
|
1228
|
+
//#region ../@warlock.js/ai-workspace/src/tools/run-shell.ts
|
|
1229
|
+
/** The default tool name `run_shell` is exposed to the LLM under. */
|
|
1230
|
+
const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
|
|
1231
|
+
/**
|
|
1232
|
+
* Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
|
|
1233
|
+
* model's arguments without a runtime schema dependency: `command` must be
|
|
1234
|
+
* a non-empty string, and `timeoutMs` (when present) a positive number.
|
|
1235
|
+
* Invalid args surface as a `SchemaValidationError` in the tool result's
|
|
1236
|
+
* `error` field rather than reaching `ops.exec`.
|
|
1237
|
+
*/
|
|
1238
|
+
const runShellInputSchema = { "~standard": {
|
|
1239
|
+
version: 1,
|
|
1240
|
+
vendor: "@warlock.js/ai-workspace",
|
|
1241
|
+
validate: (value) => {
|
|
1242
|
+
if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
|
|
1243
|
+
const candidate = value;
|
|
1244
|
+
if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
|
|
1245
|
+
message: "command must be a non-empty string",
|
|
1246
|
+
path: ["command"]
|
|
1247
|
+
}] };
|
|
1248
|
+
if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
|
|
1249
|
+
message: "timeoutMs must be a positive number",
|
|
1250
|
+
path: ["timeoutMs"]
|
|
1251
|
+
}] };
|
|
1252
|
+
const result = { command: candidate.command };
|
|
1253
|
+
if (candidate.timeoutMs !== void 0) result.timeoutMs = candidate.timeoutMs;
|
|
1254
|
+
return { value: result };
|
|
1255
|
+
}
|
|
1256
|
+
} };
|
|
1257
|
+
/**
|
|
1258
|
+
* Build the `run_shell` tool — a {@link ToolContract} that runs a single
|
|
1259
|
+
* shell command through the policy-enforced {@link WorkspaceOps} layer.
|
|
1260
|
+
*
|
|
1261
|
+
* The command's leading executable basename is gated against the shell
|
|
1262
|
+
* allow/deny policy by `ops.exec`; a blocked command throws a
|
|
1263
|
+
* `WorkspacePolicyError` which the `tool()` runtime catches and surfaces
|
|
1264
|
+
* in the result's `error` field (never a thrown run-killer), so the agent
|
|
1265
|
+
* reads the refusal as tool data and self-corrects. A command that runs
|
|
1266
|
+
* but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
|
|
1267
|
+
* in `data` for the agent to inspect.
|
|
1268
|
+
*
|
|
1269
|
+
* @param ops - The policy-enforced operation layer to delegate `exec` to.
|
|
1270
|
+
* @param options - Optional tool-name override.
|
|
1271
|
+
*
|
|
1272
|
+
* @example
|
|
1273
|
+
* const runShell = makeRunShellTool(ops);
|
|
1274
|
+
* const { data, error } = await runShell.invoke({ command: "npm run build" });
|
|
1275
|
+
* if (error) handleDenied(error);
|
|
1276
|
+
* else console.log(data.exitCode, data.stdout);
|
|
1277
|
+
*/
|
|
1278
|
+
function makeRunShellTool(ops, options) {
|
|
1279
|
+
return (0, _warlock_js_ai.tool)({
|
|
1280
|
+
name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
|
|
1281
|
+
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.",
|
|
1282
|
+
action: (input) => `Running \`${input.command}\``,
|
|
1283
|
+
input: runShellInputSchema,
|
|
1284
|
+
execute: (input) => ops.exec(input.command, { timeoutMs: input.timeoutMs })
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region ../@warlock.js/ai-workspace/src/tools/run-tests.ts
|
|
1290
|
+
/** The default tool name `run_tests` is exposed to the LLM under. */
|
|
1291
|
+
const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
|
|
1292
|
+
/** The default command run when no `command` override is configured. */
|
|
1293
|
+
const DEFAULT_TEST_COMMAND = "npm test";
|
|
1294
|
+
/**
|
|
1295
|
+
* Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the
|
|
1296
|
+
* only field and is optional; when present it must be a string. Validation
|
|
1297
|
+
* happens without a runtime schema dependency, mirroring the wider tool
|
|
1298
|
+
* layer.
|
|
1299
|
+
*/
|
|
1300
|
+
const runTestsInputSchema = { "~standard": {
|
|
1301
|
+
version: 1,
|
|
1302
|
+
vendor: "@warlock.js/ai-workspace",
|
|
1303
|
+
validate: (value) => {
|
|
1304
|
+
if (value === void 0 || value === null) return { value: {} };
|
|
1305
|
+
if (typeof value !== "object") return { issues: [{ message: "expected an object" }] };
|
|
1306
|
+
const candidate = value;
|
|
1307
|
+
if (candidate.pattern !== void 0 && typeof candidate.pattern !== "string") return { issues: [{
|
|
1308
|
+
message: "pattern must be a string",
|
|
1309
|
+
path: ["pattern"]
|
|
1310
|
+
}] };
|
|
1311
|
+
const result = {};
|
|
1312
|
+
if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
|
|
1313
|
+
return { value: result };
|
|
1314
|
+
}
|
|
1315
|
+
} };
|
|
1316
|
+
/**
|
|
1317
|
+
* Build the `run_tests` tool — a {@link ToolContract} convenience over
|
|
1318
|
+
* `run_shell` that runs the workspace's configured test command through
|
|
1319
|
+
* the policy-enforced {@link WorkspaceOps} layer.
|
|
1320
|
+
*
|
|
1321
|
+
* The base command defaults to `"npm test"` and can be overridden via
|
|
1322
|
+
* `options.command`. When the model passes a `pattern`, it is appended to
|
|
1323
|
+
* the command as a path/suite filter forwarded to the runner (e.g.
|
|
1324
|
+
* `"npm test src/cart"`). Like `run_shell`, the resolved command's
|
|
1325
|
+
* executable is gated by the shell policy — a denial surfaces in the
|
|
1326
|
+
* result's `error` field — and a non-zero exit (failing tests) comes back
|
|
1327
|
+
* as `data` for the agent to read and fix.
|
|
1328
|
+
*
|
|
1329
|
+
* @param ops - The policy-enforced operation layer to delegate `exec` to.
|
|
1330
|
+
* @param options - Optional tool-name and base-command overrides.
|
|
1331
|
+
*
|
|
1332
|
+
* @example
|
|
1333
|
+
* const runTests = makeRunTestsTool(ops, { command: "pnpm test" });
|
|
1334
|
+
* const { data } = await runTests.invoke({ pattern: "cart-total" });
|
|
1335
|
+
* if (data.exitCode !== 0) inspect(data.stderr);
|
|
1336
|
+
*/
|
|
1337
|
+
function makeRunTestsTool(ops, options) {
|
|
1338
|
+
const baseCommand = options?.command ?? DEFAULT_TEST_COMMAND;
|
|
1339
|
+
return (0, _warlock_js_ai.tool)({
|
|
1340
|
+
name: options?.name ?? DEFAULT_RUN_TESTS_TOOL_NAME,
|
|
1341
|
+
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.",
|
|
1342
|
+
action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
|
|
1343
|
+
input: runTestsInputSchema,
|
|
1344
|
+
execute: (input) => {
|
|
1345
|
+
const command = input.pattern ? `${baseCommand} ${input.pattern}` : baseCommand;
|
|
1346
|
+
return ops.exec(command);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
//#endregion
|
|
1352
|
+
//#region ../@warlock.js/ai-workspace/src/tools/write-file.ts
|
|
1353
|
+
/** Default tool name exposed to the LLM. */
|
|
1354
|
+
const DEFAULT_NAME = "write_file";
|
|
1355
|
+
/** Input schema for the `write_file` tool. */
|
|
1356
|
+
const inputSchema = objectSchema({
|
|
1357
|
+
path: stringField(),
|
|
1358
|
+
content: stringField()
|
|
1359
|
+
});
|
|
1360
|
+
/**
|
|
1361
|
+
* Build the agent-facing `write_file` tool over a workspace's policy-
|
|
1362
|
+
* enforced {@link WorkspaceOps}.
|
|
1363
|
+
*
|
|
1364
|
+
* The tool validates `{ path, content }` against a Standard Schema, then
|
|
1365
|
+
* delegates to `ops.writeFile`, which atomically writes the full content
|
|
1366
|
+
* (creating parent directories) and returns the byte count and content
|
|
1367
|
+
* `hash`. The tool re-attaches the workspace-relative `path` so the
|
|
1368
|
+
* result matches the {@link WriteFileResult} wire shape.
|
|
1369
|
+
*
|
|
1370
|
+
* **Errors flow as data.** A jail escape is thrown by `ops`; the
|
|
1371
|
+
* `tool()` wrapper catches it and surfaces it in the returned `{ error }`
|
|
1372
|
+
* field — `invoke()` never throws.
|
|
1373
|
+
*
|
|
1374
|
+
* @param ops - The shared, policy-enforced operation layer.
|
|
1375
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
1376
|
+
*
|
|
1377
|
+
* @example
|
|
1378
|
+
* const writeTool = makeWriteFileTool(ops);
|
|
1379
|
+
* const { data } = await writeTool.invoke({ path: "src/new.ts", content: "export {};" });
|
|
1380
|
+
* console.log(data.bytesWritten, data.hash);
|
|
1381
|
+
*/
|
|
1382
|
+
function makeWriteFileTool(ops, options) {
|
|
1383
|
+
return (0, _warlock_js_ai.tool)({
|
|
1384
|
+
name: options?.name ?? DEFAULT_NAME,
|
|
1385
|
+
description: "Write full content to a workspace file, creating it (and any parent directories) if absent and overwriting it otherwise. The write is atomic. Returns the bytes written and the new content hash.",
|
|
1386
|
+
input: inputSchema,
|
|
1387
|
+
async execute(input) {
|
|
1388
|
+
const { hash, bytesWritten } = await ops.writeFile(input.path, input.content);
|
|
1389
|
+
return {
|
|
1390
|
+
path: input.path,
|
|
1391
|
+
bytesWritten,
|
|
1392
|
+
hash
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region ../@warlock.js/ai-workspace/src/workspace.ts
|
|
1400
|
+
/**
|
|
1401
|
+
* The full set of tool names a writable workspace vends, in a stable
|
|
1402
|
+
* canonical order so `tools.all()` is deterministic.
|
|
1403
|
+
*/
|
|
1404
|
+
const ALL_TOOL_NAMES = [
|
|
1405
|
+
"readFile",
|
|
1406
|
+
"editFile",
|
|
1407
|
+
"writeFile",
|
|
1408
|
+
"runShell",
|
|
1409
|
+
"runTests",
|
|
1410
|
+
"grep",
|
|
1411
|
+
"glob"
|
|
1412
|
+
];
|
|
1413
|
+
/**
|
|
1414
|
+
* The subset a {@link Workspace.readonly} projection exposes — the
|
|
1415
|
+
* non-mutating tools only. `editFile` / `writeFile` / `runShell` /
|
|
1416
|
+
* `runTests` are deliberately omitted so a reviewer agent has no path to
|
|
1417
|
+
* change the tree.
|
|
1418
|
+
*/
|
|
1419
|
+
const READONLY_TOOL_NAMES = [
|
|
1420
|
+
"readFile",
|
|
1421
|
+
"grep",
|
|
1422
|
+
"glob"
|
|
1423
|
+
];
|
|
1424
|
+
/**
|
|
1425
|
+
* Choose the dumb IO executor for a policy. `"mock"` selects the
|
|
1426
|
+
* in-memory backend (hermetic tests); anything else — including the
|
|
1427
|
+
* `"local"` default and an absent `backend` — selects the real-disk
|
|
1428
|
+
* local backend.
|
|
1429
|
+
*/
|
|
1430
|
+
function selectBackend(policy) {
|
|
1431
|
+
if (policy.backend === "mock") return createMockBackend();
|
|
1432
|
+
return createLocalBackend();
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* The internal {@link Workspace} implementation. Holds the resolved
|
|
1436
|
+
* backend, the policy, and the single shared {@link WorkspaceOps} seam
|
|
1437
|
+
* that both the agent-facing `.tools.*` factories and the human-facing
|
|
1438
|
+
* direct methods funnel through — one jail, one rule set, two callers.
|
|
1439
|
+
*
|
|
1440
|
+
* The `allowedTools` set narrows what `tools.*` will vend and which
|
|
1441
|
+
* mutating direct methods are permitted: a full workspace allows every
|
|
1442
|
+
* name; a {@link WorkspaceImpl.readonly} projection allows only the
|
|
1443
|
+
* read/grep/glob subset and rejects writes/edits/shell/mkdir/remove.
|
|
1444
|
+
*
|
|
1445
|
+
* Constructed via {@link workspace}; the class itself is internal.
|
|
1446
|
+
*/
|
|
1447
|
+
var WorkspaceImpl = class WorkspaceImpl {
|
|
1448
|
+
constructor(policy, allowedTools = ALL_TOOL_NAMES) {
|
|
1449
|
+
this.policy = policy;
|
|
1450
|
+
this.allowedTools = new Set(allowedTools);
|
|
1451
|
+
const backend = selectBackend(policy);
|
|
1452
|
+
this.ops = createOps(backend, policy);
|
|
1453
|
+
this.tools = this.buildTools();
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Assemble the agent-facing tool namespace. Each factory builds its
|
|
1457
|
+
* tool over the shared `ops`; `all()` returns every *allowed* tool in
|
|
1458
|
+
* canonical order and `pick(...)` returns the named subset (silently
|
|
1459
|
+
* dropping any name this projection does not allow, so a `readonly()`
|
|
1460
|
+
* workspace can never be coaxed into vending a mutating tool).
|
|
1461
|
+
*/
|
|
1462
|
+
buildTools() {
|
|
1463
|
+
const erase = (contract) => contract;
|
|
1464
|
+
const factories = {
|
|
1465
|
+
readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
|
|
1466
|
+
editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
|
|
1467
|
+
writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
|
|
1468
|
+
runShell: (opts) => erase(makeRunShellTool(this.ops, opts)),
|
|
1469
|
+
runTests: (opts) => erase(makeRunTestsTool(this.ops, opts)),
|
|
1470
|
+
grep: (opts) => erase(makeGrepTool(this.ops, opts)),
|
|
1471
|
+
glob: (opts) => erase(makeGlobTool(this.ops, opts))
|
|
1472
|
+
};
|
|
1473
|
+
const build = (name, opts) => factories[name](opts);
|
|
1474
|
+
return {
|
|
1475
|
+
all: () => ALL_TOOL_NAMES.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
|
|
1476
|
+
pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
|
|
1477
|
+
readFile: (opts) => build("readFile", opts),
|
|
1478
|
+
editFile: (opts) => build("editFile", opts),
|
|
1479
|
+
writeFile: (opts) => build("writeFile", opts),
|
|
1480
|
+
runShell: (opts) => build("runShell", opts),
|
|
1481
|
+
runTests: (opts) => build("runTests", opts),
|
|
1482
|
+
grep: (opts) => build("grep", opts),
|
|
1483
|
+
glob: (opts) => build("glob", opts)
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Reject a mutating direct method on a read-only projection — surfaced
|
|
1488
|
+
* as a {@link WorkspacePolicyError} (the same typed error a denied
|
|
1489
|
+
* command produces) so a caller branches on `error.type`.
|
|
1490
|
+
*/
|
|
1491
|
+
assertWritable(operation) {
|
|
1492
|
+
if (this.allowedTools.has("writeFile")) return;
|
|
1493
|
+
throw new WorkspacePolicyError(`Operation "${operation}" is not permitted on a read-only workspace.`, {
|
|
1494
|
+
type: "denied-command",
|
|
1495
|
+
command: operation
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
readFile(filePath, opts) {
|
|
1499
|
+
return this.ops.readFile(filePath, opts);
|
|
1500
|
+
}
|
|
1501
|
+
async writeFile(filePath, content) {
|
|
1502
|
+
this.assertWritable("writeFile");
|
|
1503
|
+
return this.ops.writeFile(filePath, content);
|
|
1504
|
+
}
|
|
1505
|
+
async editFile(input) {
|
|
1506
|
+
this.assertWritable("editFile");
|
|
1507
|
+
return this.ops.editFile(input);
|
|
1508
|
+
}
|
|
1509
|
+
async exec(command, opts) {
|
|
1510
|
+
this.assertWritable("exec");
|
|
1511
|
+
return this.ops.exec(command, opts);
|
|
1512
|
+
}
|
|
1513
|
+
grep(pattern, opts) {
|
|
1514
|
+
return this.ops.grep(pattern, opts);
|
|
1515
|
+
}
|
|
1516
|
+
glob(pattern) {
|
|
1517
|
+
return this.ops.glob(pattern);
|
|
1518
|
+
}
|
|
1519
|
+
exists(filePath) {
|
|
1520
|
+
return this.ops.exists(filePath);
|
|
1521
|
+
}
|
|
1522
|
+
async mkdir(filePath) {
|
|
1523
|
+
this.assertWritable("mkdir");
|
|
1524
|
+
return this.ops.mkdir(filePath);
|
|
1525
|
+
}
|
|
1526
|
+
async remove(filePath) {
|
|
1527
|
+
this.assertWritable("remove");
|
|
1528
|
+
return this.ops.remove(filePath);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* A read-only projection over the SAME policy — only the read/grep/glob
|
|
1532
|
+
* tools are vended and every mutating direct method rejects with a
|
|
1533
|
+
* {@link WorkspacePolicyError}. A fresh ops/backend is built from the
|
|
1534
|
+
* identical policy, so the projection sees the same jailed tree.
|
|
1535
|
+
*/
|
|
1536
|
+
readonly() {
|
|
1537
|
+
return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* A sub-jailed view rooted at `subdir` (relative to this workspace's
|
|
1541
|
+
* `cwd`). Returns a brand-new workspace whose policy is this policy
|
|
1542
|
+
* with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
|
|
1543
|
+
* same allow/deny/shell/read sub-policies, but a tighter jail root.
|
|
1544
|
+
*/
|
|
1545
|
+
scope(subdir) {
|
|
1546
|
+
return new WorkspaceImpl({
|
|
1547
|
+
...this.policy,
|
|
1548
|
+
cwd: node_path.default.join(this.policy.cwd, subdir)
|
|
1549
|
+
}, [...this.allowedTools]);
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
/**
|
|
1553
|
+
* Build a {@link Workspace} — the integrator that wires a
|
|
1554
|
+
* {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
|
|
1555
|
+
* layer, and the seven agent-facing tool factories.
|
|
1556
|
+
*
|
|
1557
|
+
* The backend is chosen from `policy.backend`: `"mock"` runs in memory
|
|
1558
|
+
* (hermetic tests); the `"local"` default (and any absent value) runs
|
|
1559
|
+
* over the real disk via `@warlock.js/fs` + `node:child_process`. The
|
|
1560
|
+
* returned workspace exposes:
|
|
1561
|
+
*
|
|
1562
|
+
* - **`tools.*`** — `readFile` / `editFile` / `writeFile` / `runShell` /
|
|
1563
|
+
* `runTests` / `grep` / `glob`, plus `all()` (every tool) and
|
|
1564
|
+
* `pick(...)` (a least-privilege subset).
|
|
1565
|
+
* - **direct methods** — `readFile` / `writeFile` / `editFile` / `exec` /
|
|
1566
|
+
* `grep` / `glob` / `exists` / `mkdir` / `remove`, each delegating 1:1
|
|
1567
|
+
* to the shared ops layer.
|
|
1568
|
+
* - **`readonly()`** — a projection that vends only read/grep/glob and
|
|
1569
|
+
* rejects every mutating direct method.
|
|
1570
|
+
* - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
|
|
1571
|
+
*
|
|
1572
|
+
* Available at runtime as `ai.workspace(policy)` once this module is
|
|
1573
|
+
* imported (it registers the verb on the shared `ai` object).
|
|
1574
|
+
*
|
|
1575
|
+
* @param policy - The policy bounding the workspace (its `cwd` is the jail root).
|
|
1576
|
+
* @returns A fully-wired {@link Workspace}.
|
|
1577
|
+
*
|
|
1578
|
+
* @example
|
|
1579
|
+
* const ws = workspace({ cwd: "/srv/acme-api", shell: { allow: ["npm"], inheritEnv: ["PATH"] } });
|
|
1580
|
+
* const dev = ai.agent({ model, tools: ws.tools.all() });
|
|
1581
|
+
* await dev.execute("Make the failing cart-total suite green.");
|
|
1582
|
+
*
|
|
1583
|
+
* @example
|
|
1584
|
+
* // Least-privilege reviewer — no write, no shell.
|
|
1585
|
+
* const reviewer = ai.agent({ model, tools: ws.readonly().tools.all() });
|
|
1586
|
+
*/
|
|
1587
|
+
function workspace(policy) {
|
|
1588
|
+
return new WorkspaceImpl(policy);
|
|
1589
|
+
}
|
|
1590
|
+
_warlock_js_ai.ai.workspace = workspace;
|
|
1591
|
+
|
|
1592
|
+
//#endregion
|
|
1593
|
+
exports.WorkspaceEditError = WorkspaceEditError;
|
|
1594
|
+
exports.WorkspacePolicyError = WorkspacePolicyError;
|
|
1595
|
+
exports.buildEnv = buildEnv;
|
|
1596
|
+
exports.createLocalBackend = createLocalBackend;
|
|
1597
|
+
exports.createMockBackend = createMockBackend;
|
|
1598
|
+
exports.createOps = createOps;
|
|
1599
|
+
exports.isCommandAllowed = isCommandAllowed;
|
|
1600
|
+
exports.makeEditFileTool = makeEditFileTool;
|
|
1601
|
+
exports.makeGlobTool = makeGlobTool;
|
|
1602
|
+
exports.makeGrepTool = makeGrepTool;
|
|
1603
|
+
exports.makeReadFileTool = makeReadFileTool;
|
|
1604
|
+
exports.makeRunShellTool = makeRunShellTool;
|
|
1605
|
+
exports.makeRunTestsTool = makeRunTestsTool;
|
|
1606
|
+
exports.makeWriteFileTool = makeWriteFileTool;
|
|
1607
|
+
exports.resolveInJail = resolveInJail;
|
|
1608
|
+
exports.workspace = workspace;
|
|
1609
|
+
//# sourceMappingURL=index.cjs.map
|