@theokit/sdk-tools 0.14.0 → 0.15.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/dist/index.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CustomTool, ConfigurationError } from '@theokit/sdk';
2
- import { InteractiveProvider } from '@theokit/sdk/interactive';
3
2
  import { FilesystemProvider } from '@theokit/sdk/filesystem';
3
+ import { SandboxProvider } from '@theokit/sdk/sandbox';
4
+ import { InteractiveProvider } from '@theokit/sdk/interactive';
4
5
 
5
6
  /**
6
7
  * `apply_patch` — built-in tool for coding agents.
@@ -89,6 +90,9 @@ declare function createSessionArtifactStore(options: SessionArtifactStoreOptions
89
90
  interface CreateEditFileToolOptions {
90
91
  /** Absolute path to the project root. Every edit is gated against this boundary. */
91
92
  projectRoot: string;
93
+ /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the read + `.bak` backup +
94
+ * write go through the backend (surface-agnostic); omitted ⇒ the local `fs` path (byte-identical). */
95
+ filesystem?: FilesystemProvider;
92
96
  }
93
97
  declare function createEditFileTool(opts: CreateEditFileToolOptions): CustomTool;
94
98
 
@@ -134,6 +138,9 @@ interface CreateGitDiffToolOptions {
134
138
  projectRoot: string;
135
139
  timeoutMs?: number;
136
140
  maxStdoutBytes?: number;
141
+ /** Optional injected execution backend (`@theokit/sdk/sandbox`) — when provided, `git diff` runs via
142
+ * `SandboxBackend.execute` (surface-agnostic); omitted ⇒ the local `git` child process (unchanged). */
143
+ sandbox?: SandboxProvider;
137
144
  }
138
145
  declare function createGitDiffTool(opts: CreateGitDiffToolOptions): CustomTool;
139
146
 
@@ -151,6 +158,9 @@ declare function createGitDiffTool(opts: CreateGitDiffToolOptions): CustomTool;
151
158
  interface CreateGlobToolOptions {
152
159
  /** Absolute path to the project root. */
153
160
  projectRoot: string;
161
+ /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the walk reads through the
162
+ * backend (Local/remote), so the tool is surface-agnostic; omitted ⇒ the local `readdir` (unchanged). */
163
+ filesystem?: FilesystemProvider;
154
164
  }
155
165
  declare function createGlobTool(opts: CreateGlobToolOptions): CustomTool;
156
166
 
@@ -734,6 +744,9 @@ interface CreateSearchTextToolOptions {
734
744
  maxMatches?: number;
735
745
  /** Skip files larger than this (bytes). Default 1 MB. */
736
746
  maxFileSize?: number;
747
+ /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the recursive walk reads
748
+ * through the backend (surface-agnostic); omitted ⇒ the local `readdir`/`readFile` walk (unchanged). */
749
+ filesystem?: FilesystemProvider;
737
750
  }
738
751
  declare function createSearchTextTool(opts: CreateSearchTextToolOptions): CustomTool;
739
752
 
@@ -760,6 +773,14 @@ interface CreateShellToolOptions {
760
773
  * guardrail, not a sandbox.
761
774
  */
762
775
  allowCatastrophic?: boolean;
776
+ /**
777
+ * Optional injected execution backend (`@theokit/sdk/sandbox`) — a backend or a per-request resolver.
778
+ * When provided, the command runs via `SandboxBackend.execute` (Local/Docker/E2B) so the tool is
779
+ * surface-agnostic (a cluster/desktop host runs it in the sandbox, not the local host); omitted ⇒ the
780
+ * local `/bin/sh -c` child process (byte-identical to before). The catastrophic-command guard runs
781
+ * before either path.
782
+ */
783
+ sandbox?: SandboxProvider;
763
784
  }
764
785
  declare function createShellTool(opts: CreateShellToolOptions): CustomTool;
765
786
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CustomTool, ConfigurationError } from '@theokit/sdk';
2
- import { InteractiveProvider } from '@theokit/sdk/interactive';
3
2
  import { FilesystemProvider } from '@theokit/sdk/filesystem';
3
+ import { SandboxProvider } from '@theokit/sdk/sandbox';
4
+ import { InteractiveProvider } from '@theokit/sdk/interactive';
4
5
 
5
6
  /**
6
7
  * `apply_patch` — built-in tool for coding agents.
@@ -89,6 +90,9 @@ declare function createSessionArtifactStore(options: SessionArtifactStoreOptions
89
90
  interface CreateEditFileToolOptions {
90
91
  /** Absolute path to the project root. Every edit is gated against this boundary. */
91
92
  projectRoot: string;
93
+ /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the read + `.bak` backup +
94
+ * write go through the backend (surface-agnostic); omitted ⇒ the local `fs` path (byte-identical). */
95
+ filesystem?: FilesystemProvider;
92
96
  }
93
97
  declare function createEditFileTool(opts: CreateEditFileToolOptions): CustomTool;
94
98
 
@@ -134,6 +138,9 @@ interface CreateGitDiffToolOptions {
134
138
  projectRoot: string;
135
139
  timeoutMs?: number;
136
140
  maxStdoutBytes?: number;
141
+ /** Optional injected execution backend (`@theokit/sdk/sandbox`) — when provided, `git diff` runs via
142
+ * `SandboxBackend.execute` (surface-agnostic); omitted ⇒ the local `git` child process (unchanged). */
143
+ sandbox?: SandboxProvider;
137
144
  }
138
145
  declare function createGitDiffTool(opts: CreateGitDiffToolOptions): CustomTool;
139
146
 
@@ -151,6 +158,9 @@ declare function createGitDiffTool(opts: CreateGitDiffToolOptions): CustomTool;
151
158
  interface CreateGlobToolOptions {
152
159
  /** Absolute path to the project root. */
153
160
  projectRoot: string;
161
+ /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the walk reads through the
162
+ * backend (Local/remote), so the tool is surface-agnostic; omitted ⇒ the local `readdir` (unchanged). */
163
+ filesystem?: FilesystemProvider;
154
164
  }
155
165
  declare function createGlobTool(opts: CreateGlobToolOptions): CustomTool;
156
166
 
@@ -734,6 +744,9 @@ interface CreateSearchTextToolOptions {
734
744
  maxMatches?: number;
735
745
  /** Skip files larger than this (bytes). Default 1 MB. */
736
746
  maxFileSize?: number;
747
+ /** Optional injected filesystem (`@theokit/sdk/filesystem`) — when provided, the recursive walk reads
748
+ * through the backend (surface-agnostic); omitted ⇒ the local `readdir`/`readFile` walk (unchanged). */
749
+ filesystem?: FilesystemProvider;
737
750
  }
738
751
  declare function createSearchTextTool(opts: CreateSearchTextToolOptions): CustomTool;
739
752
 
@@ -760,6 +773,14 @@ interface CreateShellToolOptions {
760
773
  * guardrail, not a sandbox.
761
774
  */
762
775
  allowCatastrophic?: boolean;
776
+ /**
777
+ * Optional injected execution backend (`@theokit/sdk/sandbox`) — a backend or a per-request resolver.
778
+ * When provided, the command runs via `SandboxBackend.execute` (Local/Docker/E2B) so the tool is
779
+ * surface-agnostic (a cluster/desktop host runs it in the sandbox, not the local host); omitted ⇒ the
780
+ * local `/bin/sh -c` child process (byte-identical to before). The catastrophic-command guard runs
781
+ * before either path.
782
+ */
783
+ sandbox?: SandboxProvider;
763
784
  }
764
785
  declare function createShellTool(opts: CreateShellToolOptions): CustomTool;
765
786
 
package/dist/index.js CHANGED
@@ -5,11 +5,12 @@ import { z } from 'zod';
5
5
  import { existsSync, statSync, mkdirSync, writeFileSync, realpathSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'fs';
6
6
  import { safeFilenameForId, safePathJoin as safePathJoin$1 } from '@theokit/sdk/path-safety';
7
7
  import { replaceFileAtomic } from '@theokit/sdk/persistence';
8
+ import { resolveFilesystem, FileNotFoundError, FilesystemSecurityError, FilesystemReadOnlyError, StaleFileError, FilesystemError } from '@theokit/sdk/filesystem';
8
9
  import { spawn } from 'child_process';
10
+ import { resolveSandbox } from '@theokit/sdk/sandbox';
9
11
  import { resolveInteractive, InteractiveUnavailableError, NoSuchSessionError } from '@theokit/sdk/interactive';
10
12
  import { lookup } from 'dns/promises';
11
13
  import { isIP } from 'net';
12
- import { resolveFilesystem, FileNotFoundError, FilesystemSecurityError, FilesystemReadOnlyError, StaleFileError, FilesystemError } from '@theokit/sdk/filesystem';
13
14
 
14
15
  // src/apply-patch.ts
15
16
  var PathTraversalError = class extends ConfigurationError {
@@ -348,8 +349,79 @@ ${find}`);
348
349
  }
349
350
 
350
351
  // src/edit-file.ts
352
+ function computeEdit(content, old_string, new_string) {
353
+ const exactIdx = content.indexOf(old_string);
354
+ if (exactIdx !== -1) {
355
+ return {
356
+ ok: true,
357
+ result: content.slice(0, exactIdx) + new_string + content.slice(exactIdx + old_string.length)
358
+ };
359
+ }
360
+ const normalizedContent = normalizeWhitespace(content);
361
+ const normalizedOld = normalizeWhitespace(old_string);
362
+ const normalizedIdx = normalizedContent.indexOf(normalizedOld);
363
+ if (normalizedIdx !== -1) {
364
+ const span = findOriginalSpan(content, normalizedContent, normalizedIdx, normalizedOld.length);
365
+ return {
366
+ ok: true,
367
+ result: content.slice(0, span.start) + new_string + content.slice(span.end)
368
+ };
369
+ }
370
+ try {
371
+ return { ok: true, result: replaceUnique(content, old_string, new_string) };
372
+ } catch (err) {
373
+ if (err instanceof ContextMatchError) return { ok: false, error: "no_match" };
374
+ throw err;
375
+ }
376
+ }
377
+ async function editViaBackend(filesystem, ctx, path, old_string, new_string) {
378
+ const backend = await resolveFilesystem(filesystem, ctx ?? {});
379
+ let content;
380
+ try {
381
+ content = await backend.readFile(path);
382
+ } catch {
383
+ return JSON.stringify({ ok: false, error: "not_found", path });
384
+ }
385
+ const outcome = computeEdit(content, old_string, new_string);
386
+ if (!outcome.ok) return JSON.stringify({ ok: false, error: "no_match", path });
387
+ await backend.writeFile(`${path}.bak`, content);
388
+ await backend.writeFile(path, outcome.result);
389
+ return JSON.stringify({ ok: true, replacements: 1 });
390
+ }
391
+ async function editViaLocal(projectRoot, path, old_string, new_string) {
392
+ const absolutePath = safePathJoin(projectRoot, path);
393
+ let content;
394
+ try {
395
+ content = await readFile(absolutePath, "utf-8");
396
+ } catch (err) {
397
+ const e = err;
398
+ if (e.code === "ENOENT") {
399
+ return JSON.stringify({ ok: false, error: "not_found", path });
400
+ }
401
+ throw err;
402
+ }
403
+ const outcome = computeEdit(content, old_string, new_string);
404
+ if (!outcome.ok) return JSON.stringify({ ok: false, error: "no_match", path });
405
+ await copyFile(absolutePath, `${absolutePath}.bak`);
406
+ await writeFile(absolutePath, outcome.result, "utf-8");
407
+ return JSON.stringify({ ok: true, replacements: 1 });
408
+ }
409
+ function editScopeError(path, projectRoot) {
410
+ if (isForbiddenPath(path)) {
411
+ return JSON.stringify({ ok: false, error: "forbidden_path", path });
412
+ }
413
+ try {
414
+ assertNoSymlinkEscape(safePathJoin(projectRoot, path), projectRoot);
415
+ return null;
416
+ } catch (err) {
417
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
418
+ return JSON.stringify({ ok: false, error: "path_traversal", path });
419
+ }
420
+ throw err;
421
+ }
422
+ }
351
423
  function createEditFileTool(opts) {
352
- const { projectRoot } = opts;
424
+ const { projectRoot, filesystem } = opts;
353
425
  return Tool.create({
354
426
  name: "edit_file",
355
427
  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 }.",
@@ -358,67 +430,13 @@ function createEditFileTool(opts) {
358
430
  old_string: z.string().min(1).describe("String to find in the file."),
359
431
  new_string: z.string().describe("Replacement string.")
360
432
  }),
361
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
362
- handler: async ({ path, old_string, new_string }) => {
433
+ handler: async ({ path, old_string, new_string }, ctx) => {
363
434
  if (old_string === new_string) {
364
435
  return JSON.stringify({ ok: false, error: "no_change", path });
365
436
  }
366
- if (isForbiddenPath(path)) {
367
- return JSON.stringify({ ok: false, error: "forbidden_path", path });
368
- }
369
- let absolutePath;
370
- try {
371
- absolutePath = safePathJoin(projectRoot, path);
372
- assertNoSymlinkEscape(absolutePath, projectRoot);
373
- } catch (err) {
374
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
375
- return JSON.stringify({ ok: false, error: "path_traversal", path });
376
- }
377
- throw err;
378
- }
379
- let content;
380
- try {
381
- content = await readFile(absolutePath, "utf-8");
382
- } catch (err) {
383
- const e = err;
384
- if (e.code === "ENOENT") {
385
- return JSON.stringify({ ok: false, error: "not_found", path });
386
- }
387
- throw err;
388
- }
389
- const exactIdx = content.indexOf(old_string);
390
- if (exactIdx !== -1) {
391
- await copyFile(absolutePath, `${absolutePath}.bak`);
392
- const result = content.slice(0, exactIdx) + new_string + content.slice(exactIdx + old_string.length);
393
- await writeFile(absolutePath, result, "utf-8");
394
- return JSON.stringify({ ok: true, replacements: 1 });
395
- }
396
- const normalizedContent = normalizeWhitespace(content);
397
- const normalizedOld = normalizeWhitespace(old_string);
398
- const normalizedIdx = normalizedContent.indexOf(normalizedOld);
399
- if (normalizedIdx !== -1) {
400
- const span = findOriginalSpan(
401
- content,
402
- normalizedContent,
403
- normalizedIdx,
404
- normalizedOld.length
405
- );
406
- await copyFile(absolutePath, `${absolutePath}.bak`);
407
- const result = content.slice(0, span.start) + new_string + content.slice(span.end);
408
- await writeFile(absolutePath, result, "utf-8");
409
- return JSON.stringify({ ok: true, replacements: 1 });
410
- }
411
- try {
412
- const result = replaceUnique(content, old_string, new_string);
413
- await copyFile(absolutePath, `${absolutePath}.bak`);
414
- await writeFile(absolutePath, result, "utf-8");
415
- return JSON.stringify({ ok: true, replacements: 1 });
416
- } catch (err) {
417
- if (err instanceof ContextMatchError) {
418
- return JSON.stringify({ ok: false, error: "no_match", path });
419
- }
420
- throw err;
421
- }
437
+ const scopeErr = editScopeError(path, projectRoot);
438
+ if (scopeErr !== null) return scopeErr;
439
+ return filesystem !== void 0 ? editViaBackend(filesystem, ctx, path, old_string, new_string) : editViaLocal(projectRoot, path, old_string, new_string);
422
440
  }
423
441
  });
424
442
  }
@@ -531,11 +549,27 @@ function attachChildSettlers(child, gate, onClose, onError, resolve2) {
531
549
  // src/git-diff.ts
532
550
  var DEFAULT_TIMEOUT_MS = 3e4;
533
551
  var DEFAULT_MAX_STDOUT_BYTES = 5 * 1024 * 1024;
552
+ function shq(arg) {
553
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
554
+ }
555
+ async function diffViaSandbox(sandbox, ctx, cached, path, projectRoot, timeoutMs) {
556
+ const scopeCheck = checkPathScope(path, projectRoot);
557
+ if (scopeCheck !== null) return scopeCheck;
558
+ const command = ["git", ...buildDiffArgs(cached, path)].map(shq).join(" ");
559
+ const backend = await resolveSandbox(sandbox, ctx ?? {});
560
+ const r = await backend.execute(command, { timeoutMs });
561
+ if (r.timedOut) return JSON.stringify({ ok: false, error: "timeout", timeoutMs });
562
+ if (r.exitCode !== 0) {
563
+ return /not a git repository/i.test(r.stderr) ? JSON.stringify({ ok: false, error: "not_a_repo" }) : JSON.stringify({ ok: false, error: "git_failed", stderr: r.stderr });
564
+ }
565
+ return JSON.stringify({ ok: true, diff: r.stdout, truncated: false });
566
+ }
534
567
  function createGitDiffTool(opts) {
535
568
  const {
536
569
  projectRoot,
537
570
  timeoutMs = DEFAULT_TIMEOUT_MS,
538
- maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES
571
+ maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES,
572
+ sandbox
539
573
  } = opts;
540
574
  return Tool.create({
541
575
  name: "git_diff",
@@ -544,7 +578,10 @@ function createGitDiffTool(opts) {
544
578
  path: z.string().optional().describe("Optional project-relative file or dir scope."),
545
579
  cached: z.boolean().optional().describe("If true, show staged changes (git diff --cached). Default false.")
546
580
  }),
547
- handler: async ({ path, cached }) => {
581
+ handler: async ({ path, cached }, ctx) => {
582
+ if (sandbox !== void 0) {
583
+ return diffViaSandbox(sandbox, ctx, cached, path, projectRoot, timeoutMs);
584
+ }
548
585
  if (!existsSync(join(projectRoot, ".git"))) {
549
586
  return JSON.stringify({ ok: false, error: "not_a_repo" });
550
587
  }
@@ -618,7 +655,7 @@ function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
618
655
  }
619
656
  var DEFAULT_EXCLUDES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".theo"]);
620
657
  function createGlobTool(opts) {
621
- const { projectRoot } = opts;
658
+ const { projectRoot, filesystem } = opts;
622
659
  return Tool.create({
623
660
  name: "glob_files",
624
661
  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 }.",
@@ -626,20 +663,18 @@ function createGlobTool(opts) {
626
663
  pattern: z.string().min(1).describe("Glob pattern (e.g. '**/*.ts', 'src/**/*.json')."),
627
664
  cwd: z.string().optional().describe("Project-relative subdirectory to search from.")
628
665
  }),
629
- handler: async ({ pattern, cwd }) => {
630
- let searchRoot = projectRoot;
631
- if (cwd) {
632
- try {
633
- searchRoot = safePathJoin(projectRoot, cwd);
634
- assertNoSymlinkEscape(searchRoot, projectRoot);
635
- } catch (err) {
636
- if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
637
- return JSON.stringify({ ok: false, error: "path_traversal", path: cwd });
638
- }
639
- throw err;
640
- }
641
- }
666
+ handler: async ({ pattern, cwd }, ctx) => {
667
+ const scopeErr = globScopeError(cwd, projectRoot);
668
+ if (scopeErr !== null) return scopeErr;
642
669
  const regex = globToRegex(pattern);
670
+ if (filesystem !== void 0) {
671
+ const backend = await resolveFilesystem(filesystem, ctx ?? {});
672
+ const searchRel = cwd ?? "";
673
+ const found = [];
674
+ await walkDirBackend(backend, searchRel, searchRel, regex, found);
675
+ return JSON.stringify({ ok: true, files: found.sort(), count: found.length });
676
+ }
677
+ const searchRoot = cwd ? safePathJoin(projectRoot, cwd) : projectRoot;
643
678
  const files = [];
644
679
  await walkDir(searchRoot, searchRoot, regex, files);
645
680
  const relativePaths = files.map((f) => relative(projectRoot, f)).sort();
@@ -647,6 +682,18 @@ function createGlobTool(opts) {
647
682
  }
648
683
  });
649
684
  }
685
+ function globScopeError(cwd, projectRoot) {
686
+ if (!cwd) return null;
687
+ try {
688
+ assertNoSymlinkEscape(safePathJoin(projectRoot, cwd), projectRoot);
689
+ return null;
690
+ } catch (err) {
691
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
692
+ return JSON.stringify({ ok: false, error: "path_traversal", path: cwd });
693
+ }
694
+ throw err;
695
+ }
696
+ }
650
697
  async function walkDir(base, dir, pattern, results) {
651
698
  let entries;
652
699
  try {
@@ -665,6 +712,33 @@ async function walkDir(base, dir, pattern, results) {
665
712
  }
666
713
  }
667
714
  }
715
+ async function walkDirBackend(backend, base, dir, pattern, results) {
716
+ let names;
717
+ try {
718
+ names = await backend.list(dir);
719
+ } catch {
720
+ return;
721
+ }
722
+ for (const name of names) {
723
+ await walkBackendEntry(backend, base, dir, name, pattern, results);
724
+ }
725
+ }
726
+ async function walkBackendEntry(backend, base, dir, name, pattern, results) {
727
+ if (DEFAULT_EXCLUDES.has(name)) return;
728
+ const fullRel = dir === "" ? name : `${dir}/${name}`;
729
+ const relPath = base === "" ? fullRel : relative(base, fullRel);
730
+ let st;
731
+ try {
732
+ st = await backend.stat(fullRel);
733
+ } catch {
734
+ return;
735
+ }
736
+ if (st.isDirectory) {
737
+ await walkDirBackend(backend, base, fullRel, pattern, results);
738
+ } else if (st.isFile && pattern.test(relPath)) {
739
+ results.push(fullRel);
740
+ }
741
+ }
668
742
  function globToRegex(pattern) {
669
743
  let regexStr = "";
670
744
  let i = 0;
@@ -1736,7 +1810,8 @@ function createSearchTextTool(opts) {
1736
1810
  const {
1737
1811
  projectRoot,
1738
1812
  maxMatches = DEFAULT_MAX_MATCHES,
1739
- maxFileSize = DEFAULT_MAX_FILE_SIZE
1813
+ maxFileSize = DEFAULT_MAX_FILE_SIZE,
1814
+ filesystem
1740
1815
  } = opts;
1741
1816
  return Tool.create({
1742
1817
  name: "search_text",
@@ -1745,9 +1820,7 @@ function createSearchTextTool(opts) {
1745
1820
  query: z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1746
1821
  path: z.string().optional().describe("Optional project-relative directory to scope the search.")
1747
1822
  }),
1748
- handler: async ({ query, path }) => {
1749
- const scope = resolveSearchScope(path, projectRoot);
1750
- if ("error" in scope) return scope.error;
1823
+ handler: async ({ query, path }, ctx) => {
1751
1824
  const state = {
1752
1825
  matches: [],
1753
1826
  totalMatches: 0,
@@ -1757,6 +1830,20 @@ function createSearchTextTool(opts) {
1757
1830
  maxFileSize,
1758
1831
  projectRoot
1759
1832
  };
1833
+ if (filesystem !== void 0) {
1834
+ const scopeRel = resolveScopeRel(path, projectRoot);
1835
+ if ("error" in scopeRel) return scopeRel.error;
1836
+ const backend = await resolveFilesystem(filesystem, ctx ?? {});
1837
+ await walkBackend(backend, scopeRel.rel, state);
1838
+ return JSON.stringify({
1839
+ ok: true,
1840
+ matches: state.matches,
1841
+ truncated: state.truncated,
1842
+ totalMatches: state.totalMatches
1843
+ });
1844
+ }
1845
+ const scope = resolveSearchScope(path, projectRoot);
1846
+ if ("error" in scope) return scope.error;
1760
1847
  await walk(scope.scopeAbs, state);
1761
1848
  return JSON.stringify({
1762
1849
  ok: true,
@@ -1844,11 +1931,78 @@ async function scanFile(absPath, relPath, state) {
1844
1931
  if (!recordMatch(state, relPath, i + 1, line)) return;
1845
1932
  }
1846
1933
  }
1934
+ function resolveScopeRel(path, projectRoot) {
1935
+ const scopeRel = path === void 0 || path === "" || path === "." ? "" : path;
1936
+ if (scopeRel === "") return { rel: "" };
1937
+ try {
1938
+ assertNoSymlinkEscape(safePathJoin(projectRoot, scopeRel), projectRoot);
1939
+ return { rel: scopeRel };
1940
+ } catch (err) {
1941
+ if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
1942
+ return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
1943
+ }
1944
+ throw err;
1945
+ }
1946
+ }
1947
+ async function walkBackend(backend, dirRel, state) {
1948
+ if (state.truncated) return;
1949
+ let names;
1950
+ try {
1951
+ names = await backend.list(dirRel);
1952
+ } catch {
1953
+ return;
1954
+ }
1955
+ for (const name of names) {
1956
+ if (state.truncated) return;
1957
+ await handleBackendEntry(backend, dirRel, name, state);
1958
+ }
1959
+ }
1960
+ async function handleBackendEntry(backend, dirRel, name, state) {
1961
+ const entryRel = dirRel === "" ? name : `${dirRel}/${name}`;
1962
+ if (isForbiddenPath(entryRel)) return;
1963
+ let st;
1964
+ try {
1965
+ st = await backend.stat(entryRel);
1966
+ } catch {
1967
+ return;
1968
+ }
1969
+ if (st.isDirectory) {
1970
+ await walkBackend(backend, entryRel, state);
1971
+ } else if (st.isFile) {
1972
+ await scanFileBackend(backend, entryRel, st.size, state);
1973
+ }
1974
+ }
1975
+ async function scanFileBackend(backend, relPath, size, state) {
1976
+ if (size > state.maxFileSize) return;
1977
+ let content;
1978
+ try {
1979
+ content = await backend.readFile(relPath);
1980
+ } catch {
1981
+ return;
1982
+ }
1983
+ if (content.slice(0, BINARY_PROBE_BYTES2).includes("\0")) return;
1984
+ const lines = content.split("\n");
1985
+ for (let i = 0; i < lines.length; i += 1) {
1986
+ const line = lines[i];
1987
+ if (!line.includes(state.query)) continue;
1988
+ if (!recordMatch(state, relPath, i + 1, line)) return;
1989
+ }
1990
+ }
1847
1991
  var DEFAULT_TIMEOUT_MS3 = 3e4;
1848
1992
  var MAX_TIMEOUT_MS = 3e5;
1849
1993
  var MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
1994
+ async function execViaSandbox(sandbox, ctx, command, timeoutMs) {
1995
+ const backend = await resolveSandbox(sandbox, ctx ?? {});
1996
+ const r = await backend.execute(command, { timeoutMs });
1997
+ return r.timedOut ? JSON.stringify({ ok: false, error: "timeout", timeout_ms: timeoutMs }) : JSON.stringify({ ok: true, stdout: r.stdout, stderr: r.stderr, exit_code: r.exitCode });
1998
+ }
1850
1999
  function createShellTool(opts) {
1851
- const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3, allowCatastrophic = false } = opts;
2000
+ const {
2001
+ projectRoot,
2002
+ defaultTimeoutMs = DEFAULT_TIMEOUT_MS3,
2003
+ allowCatastrophic = false,
2004
+ sandbox
2005
+ } = opts;
1852
2006
  return Tool.create({
1853
2007
  name: "shell_exec",
1854
2008
  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 }.",
@@ -1856,7 +2010,7 @@ function createShellTool(opts) {
1856
2010
  command: z.string().min(1).describe("Shell command to execute."),
1857
2011
  timeout_ms: z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000, max 300000).")
1858
2012
  }),
1859
- handler: async ({ command, timeout_ms }) => {
2013
+ handler: async ({ command, timeout_ms }, ctx) => {
1860
2014
  if (!allowCatastrophic) {
1861
2015
  const reason = catastrophicShellReason(command);
1862
2016
  if (reason) {
@@ -1865,8 +2019,8 @@ function createShellTool(opts) {
1865
2019
  }
1866
2020
  }
1867
2021
  const timeoutMs = Math.min(timeout_ms ?? defaultTimeoutMs, MAX_TIMEOUT_MS);
1868
- const result = await runShell(projectRoot, command, timeoutMs);
1869
- return result;
2022
+ if (sandbox !== void 0) return execViaSandbox(sandbox, ctx, command, timeoutMs);
2023
+ return runShell(projectRoot, command, timeoutMs);
1870
2024
  }
1871
2025
  });
1872
2026
  }