@theokit/sdk-tools 0.8.0 → 0.9.1
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 +22 -0
- package/dist/index.cjs +216 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +215 -45
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9dc221b: SE31 gap closure — wire the read-side file factories to the optional `filesystem` backend. `createReadFileTool` and `createListDirTool` now accept the same optional `filesystem` provider as `createWriteFileTool`, so a per-request / multi-tenant root isolates READS and LISTINGS too (previously only writes routed through the backend). Omitted ⇒ identical current behavior (local process fs). `createGlobTool` / `createSearchTextTool` remain on local fs in v1 — they need recursive traversal the minimal non-recursive `FilesystemBackend` seam does not expose (deferred follow-up).
|
|
8
|
+
|
|
9
|
+
## 0.9.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 3af329f: **SE31 — `Filesystem` provider seam (`@theokit/sdk/filesystem`).**
|
|
14
|
+
|
|
15
|
+
A pluggable filesystem _storage_ provider, the storage-side twin of `@theokit/sdk/sandbox`. `FilesystemBackend` is an abstract class with four methods (`readFile` / `writeFile` / `stat` / `list`), an `exists()` derived on the base, a boundary `basePath`, a `readOnly` flag, structured `stat().mtimeMs` (the read-before-write oracle for SE32), and typed errors (`FileNotFoundError` / `FilesystemSecurityError` / `FilesystemReadOnlyError` / `StaleFileError`). `LocalFilesystem` is the local-process implementation, boundary-enforced by reusing the core path-guard (traversal + symlink escape → `FilesystemSecurityError`). `FilesystemProvider` + `resolveFilesystem` support a per-request resolver `(ctx) => FilesystemBackend` for multi-tenant roots.
|
|
16
|
+
|
|
17
|
+
Unlike `SandboxBackend` (whose file ops shell out via `execute`, require command execution, and give no structured `stat`), a `FilesystemBackend` serves a filesystem-only workspace with no sandbox — see ADR 0011 for why file ops are NOT routed through `SandboxBackend`. `@theokit/sdk-tools`' `createWriteFileTool` now accepts an optional `filesystem` backend (writes route through it; omitted ⇒ identical local-`projectRoot` behavior). This is the backend seam, NOT a bundled `Workspace` and NOT a new toolset — bring-your-own-tools stands; `mounts`/FUSE, S3/GCS, and LSP remain out of core. From the Mastra Workspaces comparison (SDK Evolution roadmap SE31).
|
|
18
|
+
|
|
19
|
+
- 84df83a: **SE32 — read-before-write safety (`requireReadBeforeWrite` + `ReadTracker`).**
|
|
20
|
+
|
|
21
|
+
An opt-in guard on `createWriteFileTool` that refuses to blindly overwrite a file the agent has not seen. A per-run `ReadTracker` (exported from `@theokit/sdk-tools`) records each file's mtime when `createReadFileTool` reads it; when `createWriteFileTool` is created with `{ requireReadBeforeWrite: true, readTracker }`, a write is refused with `read_required` if the existing file was never read, or `stale_file` if it changed on disk since it was read. A NEW file writes freely (nothing to clobber). Default OFF — omitting the flag preserves current behavior exactly.
|
|
22
|
+
|
|
23
|
+
Works on both the local `projectRoot` path and the SE31 `filesystem` backend path (the backend also gets `expectedMtime` forwarded so it re-checks at write time — TOCTOU defense). The tracker is deliberately per-instance, not a global singleton, so state never leaks across runs. `edit_file` already has implicit read-before-write safety via `old_string` content matching, so the guard targets the blind-overwrite path (`write_file`). Mirrors Mastra Workspaces' read-before-write (`FileReadRequiredError` / `StaleFileError`). From the Mastra Workspaces comparison (SDK Evolution roadmap SE32).
|
|
24
|
+
|
|
3
25
|
## 0.8.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/dist/index.cjs
CHANGED
|
@@ -10,6 +10,7 @@ var pathSafety = require('@theokit/sdk/path-safety');
|
|
|
10
10
|
var child_process = require('child_process');
|
|
11
11
|
var promises$1 = require('dns/promises');
|
|
12
12
|
var net = require('net');
|
|
13
|
+
var filesystem = require('@theokit/sdk/filesystem');
|
|
13
14
|
|
|
14
15
|
// src/apply-patch.ts
|
|
15
16
|
var PathTraversalError = class extends sdk.ConfigurationError {
|
|
@@ -104,7 +105,7 @@ function isForbiddenPath(input) {
|
|
|
104
105
|
// src/apply-patch.ts
|
|
105
106
|
function createApplyPatchTool(opts) {
|
|
106
107
|
const { projectRoot } = opts;
|
|
107
|
-
return sdk.
|
|
108
|
+
return sdk.Tool.create({
|
|
108
109
|
name: "apply_patch",
|
|
109
110
|
description: "Apply a unified diff patch to project files. Each file in the diff is security-checked against the project root. Creates .bak backups before modifying. Returns { ok, files_patched } or { ok: false, error }.",
|
|
110
111
|
inputSchema: zod.z.object({
|
|
@@ -266,7 +267,7 @@ function createSessionArtifactStore(options) {
|
|
|
266
267
|
}
|
|
267
268
|
function createEditFileTool(opts) {
|
|
268
269
|
const { projectRoot } = opts;
|
|
269
|
-
return sdk.
|
|
270
|
+
return sdk.Tool.create({
|
|
270
271
|
name: "edit_file",
|
|
271
272
|
description: "Make an exact string replacement in a project-relative file. Replaces the FIRST occurrence of old_string with new_string (a whitespace-normalized fallback is attempted if the exact match fails) and writes a .bak backup first. Read the file first so old_string matches the on-disk text exactly; include enough surrounding context to make it unique \u2014 only the first match is replaced, so a too-short old_string can edit the wrong location. old_string must be non-empty and differ from new_string; to change every occurrence, call edit_file repeatedly. Returns { ok, replacements } or { ok: false, error }.",
|
|
272
273
|
inputSchema: zod.z.object({
|
|
@@ -443,7 +444,7 @@ function createGitDiffTool(opts) {
|
|
|
443
444
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
444
445
|
maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES
|
|
445
446
|
} = opts;
|
|
446
|
-
return sdk.
|
|
447
|
+
return sdk.Tool.create({
|
|
447
448
|
name: "git_diff",
|
|
448
449
|
description: "Return the unified diff of the project's working tree (or staged changes when cached=true). Scoped to a single file when 'path' is provided. Requires the project to be a git repository. Returns { ok, diff, truncated? } or { ok: false, error }.",
|
|
449
450
|
inputSchema: zod.z.object({
|
|
@@ -525,7 +526,7 @@ function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
|
|
|
525
526
|
var DEFAULT_EXCLUDES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".theo"]);
|
|
526
527
|
function createGlobTool(opts) {
|
|
527
528
|
const { projectRoot } = opts;
|
|
528
|
-
return sdk.
|
|
529
|
+
return sdk.Tool.create({
|
|
529
530
|
name: "glob_files",
|
|
530
531
|
description: "Find files by glob pattern across the project \u2014 fast at any repo size. Use glob_files when you know the filename SHAPE; use search_text when you know the file CONTENT; use read_file when you know the exact path. The pattern supports * and ** wildcards (e.g. '**/*.ts', 'src/**/*.json'); node_modules/.git/dist/.theo are excluded and results are relative paths. Returns { ok, files } or { ok: false, error }.",
|
|
531
532
|
inputSchema: zod.z.object({
|
|
@@ -1054,7 +1055,10 @@ function withToolResultGuidance(tool, guidance) {
|
|
|
1054
1055
|
name: tool.name,
|
|
1055
1056
|
description: tool.description,
|
|
1056
1057
|
inputSchema: tool.inputSchema,
|
|
1057
|
-
handler: async (input) =>
|
|
1058
|
+
handler: async (input) => {
|
|
1059
|
+
const out = await tool.handler(input);
|
|
1060
|
+
return typeof out === "string" ? injectGuidance(out, guidance) : out;
|
|
1061
|
+
}
|
|
1058
1062
|
};
|
|
1059
1063
|
}
|
|
1060
1064
|
function withDefaultGuidance(tool) {
|
|
@@ -1068,6 +1072,7 @@ function withShellExitGuidance(tool) {
|
|
|
1068
1072
|
inputSchema: tool.inputSchema,
|
|
1069
1073
|
handler: async (input) => {
|
|
1070
1074
|
const out = await tool.handler(input);
|
|
1075
|
+
if (typeof out !== "string") return out;
|
|
1071
1076
|
let parsed;
|
|
1072
1077
|
try {
|
|
1073
1078
|
parsed = JSON.parse(out);
|
|
@@ -1084,26 +1089,61 @@ function withShellExitGuidance(tool) {
|
|
|
1084
1089
|
}
|
|
1085
1090
|
var DEFAULT_MAX_ENTRIES = 500;
|
|
1086
1091
|
function createListDirTool(opts) {
|
|
1087
|
-
const { projectRoot, max = DEFAULT_MAX_ENTRIES } = opts;
|
|
1088
|
-
return sdk.
|
|
1092
|
+
const { projectRoot, max = DEFAULT_MAX_ENTRIES, filesystem: filesystem$1 } = opts;
|
|
1093
|
+
return sdk.Tool.create({
|
|
1089
1094
|
name: "list_dir",
|
|
1090
1095
|
description: `Return the direct entries of a project-relative directory. Refuses paths outside the project root or in the sensitive-file blocklist (.env, .git/, node_modules/, .theo/, lock files). Caps at ${String(max)} entries by default; result carries truncated + totalCount.`,
|
|
1091
1096
|
inputSchema: zod.z.object({
|
|
1092
1097
|
path: zod.z.string().min(1).describe("Project-relative directory path. Use '.' for root.")
|
|
1093
1098
|
}),
|
|
1094
|
-
handler: async ({ path }) => {
|
|
1099
|
+
handler: async ({ path }, ctx) => {
|
|
1095
1100
|
const relative2 = path === "" || path === "." ? "." : path;
|
|
1096
1101
|
if (relative2 !== "." && isForbiddenPath(relative2)) {
|
|
1097
1102
|
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
1098
1103
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
return
|
|
1104
|
+
if (filesystem$1) {
|
|
1105
|
+
const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
|
|
1106
|
+
return listViaBackend(backend, relative2, path, max);
|
|
1107
|
+
}
|
|
1108
|
+
return listViaLocalFs(projectRoot, relative2, path, max);
|
|
1104
1109
|
}
|
|
1105
1110
|
});
|
|
1106
1111
|
}
|
|
1112
|
+
async function listViaLocalFs(projectRoot, relative2, originalPath, max) {
|
|
1113
|
+
const boundary = resolveDirBoundary(relative2, projectRoot, originalPath);
|
|
1114
|
+
if ("error" in boundary) return boundary.error;
|
|
1115
|
+
const readResult = await readDirSafe(boundary.absolutePath, originalPath);
|
|
1116
|
+
if ("error" in readResult) return readResult.error;
|
|
1117
|
+
return formatListing(readResult.dirents, max);
|
|
1118
|
+
}
|
|
1119
|
+
async function listViaBackend(backend, relative2, originalPath, max) {
|
|
1120
|
+
let names;
|
|
1121
|
+
try {
|
|
1122
|
+
names = await backend.list(relative2);
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
if (err instanceof filesystem.FileNotFoundError) {
|
|
1125
|
+
return JSON.stringify({ ok: false, error: "not_found", path: originalPath });
|
|
1126
|
+
}
|
|
1127
|
+
if (err instanceof filesystem.FilesystemSecurityError) {
|
|
1128
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path: originalPath });
|
|
1129
|
+
}
|
|
1130
|
+
throw err;
|
|
1131
|
+
}
|
|
1132
|
+
const totalCount = names.length;
|
|
1133
|
+
const windowed = names.slice(0, max);
|
|
1134
|
+
const entries = await Promise.all(
|
|
1135
|
+
windowed.map(async (name) => {
|
|
1136
|
+
const child = relative2 === "." ? name : `${relative2}/${name}`;
|
|
1137
|
+
let type = "file";
|
|
1138
|
+
try {
|
|
1139
|
+
type = (await backend.stat(child)).isDirectory ? "directory" : "file";
|
|
1140
|
+
} catch {
|
|
1141
|
+
}
|
|
1142
|
+
return { name, type };
|
|
1143
|
+
})
|
|
1144
|
+
);
|
|
1145
|
+
return JSON.stringify({ ok: true, entries, truncated: totalCount > max, totalCount });
|
|
1146
|
+
}
|
|
1107
1147
|
function resolveDirBoundary(relative2, projectRoot, originalPath) {
|
|
1108
1148
|
try {
|
|
1109
1149
|
const absolutePath = relative2 === "." ? projectRoot : safePathJoin(projectRoot, relative2);
|
|
@@ -1264,29 +1304,65 @@ function createQuestionTool(opts) {
|
|
|
1264
1304
|
var MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
1265
1305
|
var BINARY_PROBE_BYTES = 8 * 1024;
|
|
1266
1306
|
function createReadFileTool(opts) {
|
|
1267
|
-
const { projectRoot } = opts;
|
|
1268
|
-
return sdk.
|
|
1307
|
+
const { projectRoot, readTracker, filesystem: filesystem$1 } = opts;
|
|
1308
|
+
return sdk.Tool.create({
|
|
1269
1309
|
name: "read_file",
|
|
1270
1310
|
description: "Read a project-relative text file as UTF-8. ALWAYS read a file before you edit it (edit_file) or overwrite it (write_file), so your old_string / new content matches the real bytes exactly. Returns the WHOLE file (there is no offset or line-range parameter); to locate a symbol inside a large file, use search_text instead of re-reading. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary files (null byte in the first 8 KB); caps at 5 MB. Returns { ok, content, size } or { ok: false, error }.",
|
|
1271
1311
|
inputSchema: zod.z.object({
|
|
1272
1312
|
path: zod.z.string().min(1).describe("Project-relative file path.")
|
|
1273
1313
|
}),
|
|
1274
|
-
handler: async ({ path }) => {
|
|
1314
|
+
handler: async ({ path }, ctx) => {
|
|
1275
1315
|
if (isForbiddenPath(path)) {
|
|
1276
1316
|
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
1277
1317
|
}
|
|
1318
|
+
if (filesystem$1) {
|
|
1319
|
+
const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
|
|
1320
|
+
return readViaBackend(backend, path, (mtimeMs) => readTracker?.record(path, mtimeMs));
|
|
1321
|
+
}
|
|
1278
1322
|
const boundary = resolveBoundary(path, projectRoot);
|
|
1279
1323
|
if ("error" in boundary) return boundary.error;
|
|
1280
1324
|
const opened = await openHandleSafe(boundary.absolutePath, path);
|
|
1281
1325
|
if ("error" in opened) return opened.error;
|
|
1282
1326
|
try {
|
|
1283
|
-
return await readContent(
|
|
1327
|
+
return await readContent(
|
|
1328
|
+
opened.handle,
|
|
1329
|
+
path,
|
|
1330
|
+
(mtimeMs) => readTracker?.record(path, mtimeMs)
|
|
1331
|
+
);
|
|
1284
1332
|
} finally {
|
|
1285
1333
|
await opened.handle.close();
|
|
1286
1334
|
}
|
|
1287
1335
|
}
|
|
1288
1336
|
});
|
|
1289
1337
|
}
|
|
1338
|
+
async function readViaBackend(backend, path, onRead) {
|
|
1339
|
+
try {
|
|
1340
|
+
const stat2 = await backend.stat(path);
|
|
1341
|
+
if (stat2.size > MAX_FILE_SIZE) {
|
|
1342
|
+
return JSON.stringify({
|
|
1343
|
+
ok: false,
|
|
1344
|
+
error: "too_large",
|
|
1345
|
+
path,
|
|
1346
|
+
size: stat2.size,
|
|
1347
|
+
limit: MAX_FILE_SIZE
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
const content = await backend.readFile(path);
|
|
1351
|
+
if (content.includes("\0")) {
|
|
1352
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
|
|
1353
|
+
}
|
|
1354
|
+
onRead?.(stat2.mtimeMs);
|
|
1355
|
+
return JSON.stringify({ ok: true, content, size: stat2.size });
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
if (err instanceof filesystem.FileNotFoundError) {
|
|
1358
|
+
return JSON.stringify({ ok: false, error: "not_found", path });
|
|
1359
|
+
}
|
|
1360
|
+
if (err instanceof filesystem.FilesystemSecurityError) {
|
|
1361
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path });
|
|
1362
|
+
}
|
|
1363
|
+
throw err;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1290
1366
|
function resolveBoundary(path, projectRoot) {
|
|
1291
1367
|
try {
|
|
1292
1368
|
const absolutePath = safePathJoin(projectRoot, path);
|
|
@@ -1311,7 +1387,7 @@ async function openHandleSafe(absolutePath, path) {
|
|
|
1311
1387
|
throw err;
|
|
1312
1388
|
}
|
|
1313
1389
|
}
|
|
1314
|
-
async function readContent(handle, path) {
|
|
1390
|
+
async function readContent(handle, path, onRead) {
|
|
1315
1391
|
const stat2 = await handle.stat();
|
|
1316
1392
|
if (stat2.size > MAX_FILE_SIZE) {
|
|
1317
1393
|
return JSON.stringify({
|
|
@@ -1326,6 +1402,7 @@ async function readContent(handle, path) {
|
|
|
1326
1402
|
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat2.size });
|
|
1327
1403
|
}
|
|
1328
1404
|
const content = await handle.readFile({ encoding: "utf-8" });
|
|
1405
|
+
onRead?.(stat2.mtimeMs);
|
|
1329
1406
|
return JSON.stringify({ ok: true, content, size: stat2.size });
|
|
1330
1407
|
}
|
|
1331
1408
|
async function isBinaryProbe(handle, size) {
|
|
@@ -1338,6 +1415,26 @@ async function isBinaryProbe(handle, size) {
|
|
|
1338
1415
|
}
|
|
1339
1416
|
return false;
|
|
1340
1417
|
}
|
|
1418
|
+
|
|
1419
|
+
// src/read-tracker.ts
|
|
1420
|
+
var ReadTracker = class {
|
|
1421
|
+
seen = /* @__PURE__ */ new Map();
|
|
1422
|
+
/** Record the mtime observed when `path` was read. */
|
|
1423
|
+
record(path, mtimeMs) {
|
|
1424
|
+
this.seen.set(path, mtimeMs);
|
|
1425
|
+
}
|
|
1426
|
+
/** The mtime last recorded for `path`, or `undefined` if never read. */
|
|
1427
|
+
expected(path) {
|
|
1428
|
+
return this.seen.get(path);
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
function evaluateReadBeforeWrite(tracker, path, currentMtimeMs) {
|
|
1432
|
+
if (currentMtimeMs === null) return "ok";
|
|
1433
|
+
const recorded = tracker.expected(path);
|
|
1434
|
+
if (recorded === void 0) return "read_required";
|
|
1435
|
+
if (recorded !== currentMtimeMs) return "stale";
|
|
1436
|
+
return "ok";
|
|
1437
|
+
}
|
|
1341
1438
|
var DEFAULT_TIMEOUT_MS2 = 12e4;
|
|
1342
1439
|
var DEFAULT_MAX_STDOUT_BYTES2 = 10 * 1024 * 1024;
|
|
1343
1440
|
function createRunVitestTool(opts) {
|
|
@@ -1346,7 +1443,7 @@ function createRunVitestTool(opts) {
|
|
|
1346
1443
|
timeoutMs = DEFAULT_TIMEOUT_MS2,
|
|
1347
1444
|
maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES2
|
|
1348
1445
|
} = opts;
|
|
1349
|
-
return sdk.
|
|
1446
|
+
return sdk.Tool.create({
|
|
1350
1447
|
name: "run_vitest",
|
|
1351
1448
|
description: "Run the project's vitest suite, optionally scoped to a file or pattern via 'path'. Returns parsed { ok, summary } or { ok: false, error }. Vitest stdout warnings are stripped \u2014 the parser extracts the trailing JSON report.",
|
|
1352
1449
|
inputSchema: zod.z.object({
|
|
@@ -1455,7 +1552,7 @@ function createSearchTextTool(opts) {
|
|
|
1455
1552
|
maxMatches = DEFAULT_MAX_MATCHES,
|
|
1456
1553
|
maxFileSize = DEFAULT_MAX_FILE_SIZE
|
|
1457
1554
|
} = opts;
|
|
1458
|
-
return sdk.
|
|
1555
|
+
return sdk.Tool.create({
|
|
1459
1556
|
name: "search_text",
|
|
1460
1557
|
description: `Search file CONTENTS for a LITERAL, CASE-SENSITIVE query across the project tree (the query is matched as a substring, not a regex). Use search_text when you know the content; use glob_files when you know the filename shape; use read_file when you know the exact path. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB; 'path' scopes the search to a subdirectory. Returns up to ${String(maxMatches)} matches as { file, line, preview } \u2014 cite locations to the user as file:line. Returns { ok, matches } or { ok: false, error }.`,
|
|
1461
1558
|
inputSchema: zod.z.object({
|
|
@@ -1566,7 +1663,7 @@ var MAX_TIMEOUT_MS = 3e5;
|
|
|
1566
1663
|
var MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
1567
1664
|
function createShellTool(opts) {
|
|
1568
1665
|
const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3, allowCatastrophic = false } = opts;
|
|
1569
|
-
return sdk.
|
|
1666
|
+
return sdk.Tool.create({
|
|
1570
1667
|
name: "shell_exec",
|
|
1571
1668
|
description: "Execute a shell command in the project directory. Use this for terminal operations \u2014 running tests, git, package managers, build tools. Do NOT use it for file operations (reading, writing, editing, finding files): prefer the specialized read_file/write_file/edit_file/glob_files/search_text tools, which are path-checked and safer. Only commit, push, or change git state when the user explicitly asks. timeout_ms defaults to 30000 (max 300000); stdout/stderr are capped (~5 MB). Returns { ok, stdout, stderr, exit_code } or { ok: false, error }.",
|
|
1572
1669
|
inputSchema: zod.z.object({
|
|
@@ -1803,7 +1900,7 @@ function createWebFetchTool(opts) {
|
|
|
1803
1900
|
const maxRedirects = opts?.maxRedirects;
|
|
1804
1901
|
const fetchImpl = opts?.fetchImpl;
|
|
1805
1902
|
const lookup = opts?.lookup;
|
|
1806
|
-
return sdk.
|
|
1903
|
+
return sdk.Tool.create({
|
|
1807
1904
|
name: "web_fetch",
|
|
1808
1905
|
description: "Fetch the contents of a URL via HTTP/HTTPS. Use only for URLs the user provided or that you are confident help with the task; never invent or guess URLs. Rejects non-http(s) URLs and is SSRF-guarded by default (private/loopback/link-local/cloud-metadata hosts are refused with an ssrf_blocked error). The response body is capped at 1 MB. Returns { ok, content, status_code, content_type } or { ok: false, error }.",
|
|
1809
1906
|
inputSchema: zod.z.object({
|
|
@@ -1890,7 +1987,7 @@ function createWebFetchTool(opts) {
|
|
|
1890
1987
|
}
|
|
1891
1988
|
function createWebSearchTool(opts) {
|
|
1892
1989
|
const { search, defaultMaxResults = 5 } = opts;
|
|
1893
|
-
return sdk.
|
|
1990
|
+
return sdk.Tool.create({
|
|
1894
1991
|
name: "web_search",
|
|
1895
1992
|
description: "Search the web for a query \u2014 use when you need current information beyond the repo or your training cutoff (library docs, an error message, an API). Returns a list of results with title, URL, and snippet; follow up with web_fetch on a promising result to read it in full. The search provider is injected by the consumer. Returns { ok, results } or { ok: false, error }.",
|
|
1896
1993
|
inputSchema: zod.z.object({
|
|
@@ -1970,38 +2067,111 @@ function createGenericHttpSearchAdapter(opts = {}) {
|
|
|
1970
2067
|
}
|
|
1971
2068
|
var BINARY_PROBE_BYTES3 = 8 * 1024;
|
|
1972
2069
|
function createWriteFileTool(opts) {
|
|
1973
|
-
const { projectRoot } = opts;
|
|
1974
|
-
|
|
2070
|
+
const { projectRoot, filesystem: filesystem$1 } = opts;
|
|
2071
|
+
if (opts.requireReadBeforeWrite && !opts.readTracker) {
|
|
2072
|
+
throw new Error(
|
|
2073
|
+
"createWriteFileTool: requireReadBeforeWrite is true but no readTracker was provided \u2014 pass the same ReadTracker instance to createReadFileTool and createWriteFileTool."
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
const guard = opts.requireReadBeforeWrite ? opts.readTracker : void 0;
|
|
2077
|
+
return sdk.Tool.create({
|
|
1975
2078
|
name: "write_file",
|
|
1976
|
-
description: "Write UTF-8 content to a project-relative file, creating parent directories as needed. OVERWRITES any existing file at the path. Prefer editing an existing file with edit_file over rewriting it; use write_file to create a NEW file or fully replace a small one. If the file already exists, read_file it first so you do not discard content you have not seen. Refuses paths that escape the
|
|
2079
|
+
description: "Write UTF-8 content to a project-relative file, creating parent directories as needed. OVERWRITES any existing file at the path. Prefer editing an existing file with edit_file over rewriting it; use write_file to create a NEW file or fully replace a small one. If the file already exists, read_file it first so you do not discard content you have not seen. Refuses paths that escape the write root and sensitive files (.env, .git/, node_modules/, .theo/, lock files); the default local root also refuses binary-file overwrites. Returns { ok, path, bytes } or { ok: false, error }.",
|
|
1977
2080
|
inputSchema: zod.z.object({
|
|
1978
2081
|
path: zod.z.string().min(1).describe("Project-relative file path."),
|
|
1979
2082
|
content: zod.z.string().describe("UTF-8 content to write.")
|
|
1980
2083
|
}),
|
|
1981
|
-
handler: async ({ path
|
|
1982
|
-
if (isForbiddenPath(path
|
|
1983
|
-
return JSON.stringify({ ok: false, error: "forbidden_path", path
|
|
1984
|
-
}
|
|
1985
|
-
let absolutePath;
|
|
1986
|
-
try {
|
|
1987
|
-
absolutePath = safePathJoin(projectRoot, path$1);
|
|
1988
|
-
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
1989
|
-
} catch (err) {
|
|
1990
|
-
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
1991
|
-
return JSON.stringify({ ok: false, error: "path_traversal", path: path$1 });
|
|
1992
|
-
}
|
|
1993
|
-
throw err;
|
|
2084
|
+
handler: async ({ path, content }, ctx) => {
|
|
2085
|
+
if (isForbiddenPath(path)) {
|
|
2086
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
1994
2087
|
}
|
|
1995
|
-
if (
|
|
1996
|
-
|
|
2088
|
+
if (filesystem$1) {
|
|
2089
|
+
const backend = await filesystem.resolveFilesystem(filesystem$1, ctx ?? {});
|
|
2090
|
+
return writeViaBackend(backend, path, content, guard);
|
|
1997
2091
|
}
|
|
1998
|
-
|
|
1999
|
-
await promises.writeFile(absolutePath, content, "utf-8");
|
|
2000
|
-
const bytes = Buffer.byteLength(content, "utf-8");
|
|
2001
|
-
return JSON.stringify({ ok: true, path: path$1, bytes });
|
|
2092
|
+
return writeViaLocalFs(projectRoot, path, content, guard);
|
|
2002
2093
|
}
|
|
2003
2094
|
});
|
|
2004
2095
|
}
|
|
2096
|
+
function readBeforeWriteError(guard, path, currentMtimeMs) {
|
|
2097
|
+
if (!guard) return null;
|
|
2098
|
+
const decision = evaluateReadBeforeWrite(guard, path, currentMtimeMs);
|
|
2099
|
+
if (decision === "read_required")
|
|
2100
|
+
return JSON.stringify({ ok: false, error: "read_required", path });
|
|
2101
|
+
if (decision === "stale") return JSON.stringify({ ok: false, error: "stale_file", path });
|
|
2102
|
+
return null;
|
|
2103
|
+
}
|
|
2104
|
+
async function writeViaLocalFs(projectRoot, path$1, content, guard) {
|
|
2105
|
+
let absolutePath;
|
|
2106
|
+
try {
|
|
2107
|
+
absolutePath = safePathJoin(projectRoot, path$1);
|
|
2108
|
+
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
2109
|
+
} catch (err) {
|
|
2110
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
2111
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path: path$1 });
|
|
2112
|
+
}
|
|
2113
|
+
throw err;
|
|
2114
|
+
}
|
|
2115
|
+
const rbw = readBeforeWriteError(guard, path$1, await statMtimeOrNull(absolutePath));
|
|
2116
|
+
if (rbw) return rbw;
|
|
2117
|
+
if (await isBinaryFile(absolutePath)) {
|
|
2118
|
+
return JSON.stringify({ ok: false, error: "binary_file", path: path$1 });
|
|
2119
|
+
}
|
|
2120
|
+
await promises.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
2121
|
+
await promises.writeFile(absolutePath, content, "utf-8");
|
|
2122
|
+
const bytes = Buffer.byteLength(content, "utf-8");
|
|
2123
|
+
return JSON.stringify({ ok: true, path: path$1, bytes });
|
|
2124
|
+
}
|
|
2125
|
+
async function statMtimeOrNull(absolutePath) {
|
|
2126
|
+
try {
|
|
2127
|
+
return (await promises.stat(absolutePath)).mtimeMs;
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
if (err.code === "ENOENT") return null;
|
|
2130
|
+
throw err;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
async function backendMtimeOrNull(backend, path) {
|
|
2134
|
+
try {
|
|
2135
|
+
return (await backend.stat(path)).mtimeMs;
|
|
2136
|
+
} catch (err) {
|
|
2137
|
+
if (err instanceof filesystem.FileNotFoundError) return null;
|
|
2138
|
+
throw err;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
async function writeViaBackend(backend, path, content, guard) {
|
|
2142
|
+
try {
|
|
2143
|
+
let expectedMtime;
|
|
2144
|
+
if (guard) {
|
|
2145
|
+
const current = await backendMtimeOrNull(backend, path);
|
|
2146
|
+
const rbw = readBeforeWriteError(guard, path, current);
|
|
2147
|
+
if (rbw) return rbw;
|
|
2148
|
+
expectedMtime = current ?? void 0;
|
|
2149
|
+
}
|
|
2150
|
+
const stat2 = await backend.writeFile(
|
|
2151
|
+
path,
|
|
2152
|
+
content,
|
|
2153
|
+
expectedMtime !== void 0 ? { expectedMtime } : void 0
|
|
2154
|
+
);
|
|
2155
|
+
return JSON.stringify({ ok: true, path, bytes: stat2.size });
|
|
2156
|
+
} catch (err) {
|
|
2157
|
+
return backendErrorToJson(err, path);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
function backendErrorToJson(err, path) {
|
|
2161
|
+
if (err instanceof filesystem.FilesystemSecurityError) {
|
|
2162
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path });
|
|
2163
|
+
}
|
|
2164
|
+
if (err instanceof filesystem.FilesystemReadOnlyError) {
|
|
2165
|
+
return JSON.stringify({ ok: false, error: "read_only", path });
|
|
2166
|
+
}
|
|
2167
|
+
if (err instanceof filesystem.StaleFileError) {
|
|
2168
|
+
return JSON.stringify({ ok: false, error: "stale_file", path });
|
|
2169
|
+
}
|
|
2170
|
+
if (err instanceof filesystem.FilesystemError) {
|
|
2171
|
+
return JSON.stringify({ ok: false, error: "write_failed", path });
|
|
2172
|
+
}
|
|
2173
|
+
throw err;
|
|
2174
|
+
}
|
|
2005
2175
|
async function isBinaryFile(absolutePath) {
|
|
2006
2176
|
let handle;
|
|
2007
2177
|
try {
|
|
@@ -2026,6 +2196,7 @@ async function isBinaryFile(absolutePath) {
|
|
|
2026
2196
|
|
|
2027
2197
|
exports.CatastrophicCommandError = CatastrophicCommandError;
|
|
2028
2198
|
exports.DEFAULT_TOOL_GUIDANCE = DEFAULT_TOOL_GUIDANCE;
|
|
2199
|
+
exports.ReadTracker = ReadTracker;
|
|
2029
2200
|
exports.RedirectBlockedError = RedirectBlockedError;
|
|
2030
2201
|
exports.SsrfBlockedError = SsrfBlockedError;
|
|
2031
2202
|
exports.buildEnvContext = buildEnvContext;
|