@lifeaitools/clauth 1.30.1 → 1.30.2

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.
@@ -24,6 +24,7 @@ import { createStudioDebugRuntime } from "../studio-debug.js";
24
24
  import { writeCredentialWithRecovery } from "../recovery.js";
25
25
  import * as fsGit from "../lib/fs-git.js";
26
26
  import * as webdavService from "../webdav-service.js";
27
+ import { handleFsTool } from "./serve/tools/fs.js";
27
28
  import {
28
29
  getWatchdogStatuses,
29
30
  readWatchdogEvents,
@@ -9815,160 +9816,6 @@ async function searchServices({ password, machineHash, token, timestamp, query,
9815
9816
  return { query, count: matches.length, matches };
9816
9817
  }
9817
9818
 
9818
- // ── Filesystem service config — loaded from clauth vault ──
9819
- let _fsMountsCache = null;
9820
- let _fsMountsCacheTime = 0;
9821
- const FS_CACHE_TTL = 60000; // 1 minute
9822
-
9823
- async function getFileserverMounts(vault) {
9824
- if (!vault.password) return { error: "Vault is locked — unlock first" };
9825
- const now = Date.now();
9826
- if (_fsMountsCache && now - _fsMountsCacheTime < FS_CACHE_TTL) return { mounts: _fsMountsCache };
9827
-
9828
- try {
9829
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
9830
- const result = await api.status(vault.password, vault.machineHash, token, timestamp);
9831
- if (result.error) return { error: result.error };
9832
- const mounts = [];
9833
- for (const s of (result.services || [])) {
9834
- if (s.key_type === "fileserver" && s.enabled) {
9835
- try {
9836
- const { token: t2, timestamp: ts2 } = deriveToken(vault.password, vault.machineHash);
9837
- const secret = await api.retrieve(vault.password, vault.machineHash, t2, ts2, s.name);
9838
- if (secret.value) {
9839
- const config = JSON.parse(secret.value);
9840
- mounts.push({ name: s.name, path: config.path, access: config.access || "r" });
9841
- }
9842
- } catch {}
9843
- }
9844
- }
9845
- _fsMountsCache = mounts;
9846
- _fsMountsCacheTime = now;
9847
- return { mounts };
9848
- } catch (err) {
9849
- return { error: `Mount lookup failed: ${err.message}` };
9850
- }
9851
- }
9852
-
9853
- async function resolveInMount(requestedPath, mountName, vault) {
9854
- const { mounts, error } = await getFileserverMounts(vault);
9855
- if (error) return { error };
9856
- if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (access flags: r=read w=write d=delete g=git)" };
9857
- const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
9858
- if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
9859
- if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
9860
- const resolved = path.resolve(mount.path, requestedPath);
9861
- const normalized = path.normalize(resolved);
9862
- const relative = path.relative(path.normalize(mount.path), normalized);
9863
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
9864
- return { error: `Path escapes mount: ${requestedPath}` };
9865
- }
9866
- return { resolved: normalized, mount };
9867
- }
9868
-
9869
- function checkAccess(mount, flag) {
9870
- return mount.access.includes(flag);
9871
- }
9872
-
9873
- function sha256Hex(value) {
9874
- return crypto.createHash("sha256").update(value).digest("hex");
9875
- }
9876
-
9877
- async function atomicWriteText(filePath, content) {
9878
- await mkdir(path.dirname(filePath), { recursive: true });
9879
- const tempPath = path.join(
9880
- path.dirname(filePath),
9881
- `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
9882
- );
9883
- await writeFile(tempPath, content, "utf8");
9884
- await rename(tempPath, filePath);
9885
- }
9886
-
9887
- async function fileInfo(filePath, requestedPath) {
9888
- const s = await stat(filePath);
9889
- const info = {
9890
- path: requestedPath,
9891
- type: s.isDirectory() ? "dir" : "file",
9892
- size: s.size,
9893
- modified: s.mtime.toISOString(),
9894
- };
9895
- if (s.isFile()) {
9896
- const content = await readFile(filePath);
9897
- info.sha256 = sha256Hex(content);
9898
- }
9899
- return info;
9900
- }
9901
-
9902
- const FS_UPLOAD_SESSIONS = new Map();
9903
- const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
9904
- const FS_MAX_CHUNKS = 500;
9905
- const FS_MAX_CHUNK_BYTES = 128 * 1024;
9906
- const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
9907
- const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
9908
- "docs/",
9909
- ".rdc/plans/",
9910
- ".rdc/guides/",
9911
- ".claude/context/",
9912
- ".claude/rules/",
9913
- ".rdc/relay/from-claude-ai/",
9914
- ];
9915
-
9916
- function cleanupFsUploadSessions() {
9917
- const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
9918
- for (const [id, session] of FS_UPLOAD_SESSIONS) {
9919
- if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
9920
- }
9921
- }
9922
-
9923
- function runGit(cwd, args, opts = {}) {
9924
- const res = spawnSync("git", args, {
9925
- cwd,
9926
- encoding: "utf8",
9927
- windowsHide: true,
9928
- maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
9929
- });
9930
- if (res.status !== 0) {
9931
- const detail = (res.stderr || res.stdout || "").trim();
9932
- throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
9933
- }
9934
- return (res.stdout || "").trim();
9935
- }
9936
-
9937
- function runGitRaw(cwd, args, opts = {}) {
9938
- const res = spawnSync("git", args, {
9939
- cwd,
9940
- encoding: "buffer",
9941
- windowsHide: true,
9942
- maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
9943
- });
9944
- if (res.status !== 0) {
9945
- const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
9946
- throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
9947
- }
9948
- return res.stdout || Buffer.alloc(0);
9949
- }
9950
-
9951
- // Read a single credential value from the vault inside an MCP handler.
9952
- // (The git operations themselves live in ../lib/fs-git.js — pure + testable.)
9953
- async function vaultRetrieveValue(vault, service) {
9954
- if (!vault.password) return { error: "locked" };
9955
- if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
9956
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
9957
- return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
9958
- }
9959
-
9960
- function normalizeRepoPath(p) {
9961
- if (!p || typeof p !== "string") return null;
9962
- const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
9963
- const parts = normalized.split("/").filter(Boolean);
9964
- if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
9965
- return parts.join("/");
9966
- }
9967
-
9968
- function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
9969
- return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
9970
- }
9971
-
9972
9819
  const MCP_TOOLS = [
9973
9820
  {
9974
9821
  name: "clauth_ping",
@@ -11006,6 +10853,14 @@ async function handleMcpTool(vault, name, args) {
11006
10853
  return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
11007
10854
  };
11008
10855
 
10856
+ // fs_* tools are extracted to serve/tools/fs.js
10857
+ if (name.startsWith("fs_")) {
10858
+ const fsCtx = { vault, mcpResult, mcpError, deriveToken, api, webdavService, fsGit };
10859
+ const fsResult = await handleFsTool(name, args, fsCtx);
10860
+ if (fsResult !== null) return fsResult;
10861
+ return mcpError(`Unknown tool: ${name}`);
10862
+ }
10863
+
11009
10864
  switch (name) {
11010
10865
  case "clauth_ping": {
11011
10866
  return mcpResult(
@@ -11468,603 +11323,6 @@ async function handleMcpTool(vault, name, args) {
11468
11323
 
11469
11324
  // ── Filesystem tools ──────────────────────────────────────
11470
11325
 
11471
- case "fs_read": {
11472
- const r = await resolveInMount(args.path, args.mount, vault);
11473
- if (r.error) return mcpError(r.error);
11474
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
11475
- try {
11476
- const content = await readFile(r.resolved, "utf8");
11477
- const lines = content.split("\n");
11478
- const offset = args.offset || 0;
11479
- const limit = args.limit || 500;
11480
- const slice = lines.slice(offset, offset + limit);
11481
- const numbered = slice.map((line, i) => `${offset + i + 1}\t${line}`).join("\n");
11482
- const header = `${r.resolved} (${lines.length} lines${offset > 0 ? `, showing ${offset + 1}-${Math.min(offset + limit, lines.length)}` : ""})`;
11483
- return mcpResult(`${header}\n${numbered}`);
11484
- } catch (err) {
11485
- if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
11486
- return mcpError(`Read failed: ${err.message}`);
11487
- }
11488
- }
11489
-
11490
- case "fs_write": {
11491
- const r = await resolveInMount(args.path, args.mount, vault);
11492
- if (r.error) return mcpError(r.error);
11493
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11494
- try {
11495
- await atomicWriteText(r.resolved, args.content);
11496
- return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
11497
- } catch (err) {
11498
- return mcpError(`Write failed: ${err.message}`);
11499
- }
11500
- }
11501
-
11502
- case "fs_stat": {
11503
- const r = await resolveInMount(args.path, args.mount, vault);
11504
- if (r.error) return mcpError(r.error);
11505
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
11506
- try {
11507
- return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
11508
- } catch (err) {
11509
- if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
11510
- return mcpError(`Stat failed: ${err.message}`);
11511
- }
11512
- }
11513
-
11514
- case "fs_append": {
11515
- const r = await resolveInMount(args.path, args.mount, vault);
11516
- if (r.error) return mcpError(r.error);
11517
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11518
- try {
11519
- try {
11520
- const current = await readFile(r.resolved);
11521
- if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
11522
- return mcpError("Append rejected: current file hash does not match expected_sha256");
11523
- }
11524
- } catch (err) {
11525
- if (err.code !== "ENOENT") throw err;
11526
- if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
11527
- }
11528
- await mkdir(path.dirname(r.resolved), { recursive: true });
11529
- await appendFile(r.resolved, args.content, "utf8");
11530
- const info = await fileInfo(r.resolved, args.path);
11531
- return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
11532
- } catch (err) {
11533
- return mcpError(`Append failed: ${err.message}`);
11534
- }
11535
- }
11536
-
11537
- case "fs_write_chunk": {
11538
- cleanupFsUploadSessions();
11539
- const { upload_id, chunk_index, total_chunks, content } = args;
11540
- const index = Number(chunk_index);
11541
- const total = Number(total_chunks);
11542
- if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
11543
- return mcpError("Invalid chunk_index/total_chunks");
11544
- }
11545
- if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
11546
- if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
11547
- return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
11548
- }
11549
-
11550
- const r = await resolveInMount(args.path, args.mount, vault);
11551
- if (r.error) return mcpError(r.error);
11552
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11553
-
11554
- const key = `${r.mount.name}:${args.path}:${upload_id}`;
11555
- let session = FS_UPLOAD_SESSIONS.get(key);
11556
- if (!session) {
11557
- session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
11558
- FS_UPLOAD_SESSIONS.set(key, session);
11559
- }
11560
- if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
11561
- return mcpError("Upload id collision: path or total_chunks differs from existing session");
11562
- }
11563
- if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
11564
- return mcpError("Upload id collision: expected_sha256 differs from existing session");
11565
- }
11566
-
11567
- session.chunks.set(index, content);
11568
- session.updatedAt = Date.now();
11569
-
11570
- if (session.chunks.size < total) {
11571
- return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
11572
- }
11573
-
11574
- const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
11575
- const actualSha = sha256Hex(assembled);
11576
- if (session.expectedSha256 && actualSha !== session.expectedSha256) {
11577
- FS_UPLOAD_SESSIONS.delete(key);
11578
- return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
11579
- }
11580
-
11581
- try {
11582
- await atomicWriteText(r.resolved, assembled);
11583
- FS_UPLOAD_SESSIONS.delete(key);
11584
- return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
11585
- } catch (err) {
11586
- return mcpError(`Chunked write failed: ${err.message}`);
11587
- }
11588
- }
11589
-
11590
- case "fs_ingest_url": {
11591
- const r = await resolveInMount(args.path, args.mount, vault);
11592
- if (r.error) return mcpError(r.error);
11593
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11594
-
11595
- let url;
11596
- try {
11597
- url = new URL(args.url);
11598
- } catch {
11599
- return mcpError("Invalid URL");
11600
- }
11601
- if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
11602
-
11603
- const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
11604
- try {
11605
- const response = await fetch(url, { redirect: "follow" });
11606
- if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
11607
- const length = Number(response.headers.get("content-length") || 0);
11608
- if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
11609
-
11610
- const reader = response.body?.getReader();
11611
- if (!reader) return mcpError("Fetch failed: response body is not readable");
11612
-
11613
- let received = 0;
11614
- const chunks = [];
11615
- while (true) {
11616
- const { done, value } = await reader.read();
11617
- if (done) break;
11618
- received += value.byteLength;
11619
- if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
11620
- chunks.push(Buffer.from(value));
11621
- }
11622
-
11623
- const content = Buffer.concat(chunks).toString("utf8");
11624
- const actualSha = sha256Hex(content);
11625
- if (args.expected_sha256 && actualSha !== args.expected_sha256) {
11626
- return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
11627
- }
11628
- await atomicWriteText(r.resolved, content);
11629
- return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
11630
- } catch (err) {
11631
- return mcpError(`Ingest failed: ${err.message}`);
11632
- }
11633
- }
11634
-
11635
- case "fs_import_git_files": {
11636
- if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
11637
- if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
11638
-
11639
- const r = await resolveInMount(".", args.mount, vault);
11640
- if (r.error) return mcpError(r.error);
11641
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11642
-
11643
- const repoRoot = r.resolved;
11644
- const remote = args.remote || "origin";
11645
- const mode = args.mode || "new_only";
11646
- const doCommit = args.commit === true;
11647
- const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
11648
- ? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
11649
- : FS_GIT_IMPORT_ALLOWED_PREFIXES;
11650
-
11651
- try {
11652
- const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
11653
- if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
11654
- return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
11655
- }
11656
-
11657
- const normalizedPaths = [];
11658
- for (const rawPath of args.paths) {
11659
- const normalized = normalizeRepoPath(rawPath);
11660
- if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
11661
- if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
11662
- normalizedPaths.push(normalized);
11663
- }
11664
-
11665
- if (mode === "new_only") {
11666
- for (const rel of normalizedPaths) {
11667
- const localPath = path.join(repoRoot, rel);
11668
- try {
11669
- await stat(localPath);
11670
- return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
11671
- } catch (err) {
11672
- if (err.code !== "ENOENT") throw err;
11673
- }
11674
- }
11675
- }
11676
-
11677
- if (doCommit) {
11678
- const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
11679
- if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
11680
- if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
11681
- }
11682
-
11683
- runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
11684
- const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
11685
-
11686
- for (const rel of normalizedPaths) {
11687
- runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
11688
- }
11689
-
11690
- runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
11691
-
11692
- const imported = [];
11693
- for (const rel of normalizedPaths) {
11694
- const localPath = path.join(repoRoot, rel);
11695
- const info = await fileInfo(localPath, rel);
11696
- const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
11697
- const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
11698
- imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
11699
- }
11700
-
11701
- let localCommit = null;
11702
- if (doCommit) {
11703
- runGit(repoRoot, ["add", "--", ...normalizedPaths]);
11704
- const body = [
11705
- args.message.trim(),
11706
- "",
11707
- "Imported from Claude.ai GitHub upload.",
11708
- "",
11709
- `Source remote: ${remote}`,
11710
- `Source ref: ${args.ref}`,
11711
- `Source commit: ${sourceCommit}`,
11712
- "",
11713
- "Paths:",
11714
- ...normalizedPaths.map((p) => `- ${p}`),
11715
- ].join("\n");
11716
- runGit(repoRoot, ["commit", "-m", body]);
11717
- localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
11718
- }
11719
-
11720
- return mcpResult(JSON.stringify({
11721
- status: "ok",
11722
- mode,
11723
- committed: doCommit,
11724
- source_commit: sourceCommit,
11725
- local_commit: localCommit,
11726
- imported,
11727
- }, null, 2));
11728
- } catch (err) {
11729
- return mcpError(`Git import failed: ${err.message}`);
11730
- }
11731
- }
11732
-
11733
- case "fs_list": {
11734
- const dirPath = args.path || ".";
11735
- const r = await resolveInMount(dirPath, args.mount, vault);
11736
- if (r.error) return mcpError(r.error);
11737
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
11738
- try {
11739
- const entries = await readdir(r.resolved, { withFileTypes: true });
11740
- const results = [];
11741
- for (const entry of entries) {
11742
- try {
11743
- const s = await stat(path.join(r.resolved, entry.name));
11744
- results.push({
11745
- name: entry.name,
11746
- type: entry.isDirectory() ? "dir" : "file",
11747
- size: s.size,
11748
- modified: s.mtime.toISOString(),
11749
- });
11750
- } catch {
11751
- results.push({ name: entry.name, type: entry.isDirectory() ? "dir" : "file" });
11752
- }
11753
- }
11754
- return mcpResult(JSON.stringify(results, null, 2));
11755
- } catch (err) {
11756
- if (err.code === "ENOENT") return mcpError(`Directory not found: ${dirPath}`);
11757
- return mcpError(`List failed: ${err.message}`);
11758
- }
11759
- }
11760
-
11761
- case "fs_grep": {
11762
- const searchPath = args.path || ".";
11763
- const r = await resolveInMount(searchPath, args.mount, vault);
11764
- if (r.error) return mcpError(r.error);
11765
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
11766
-
11767
- const maxResults = args.max_results || 50;
11768
- const rgArgs = [
11769
- "--no-heading", "--line-number", "--color", "never",
11770
- "--max-count", String(maxResults),
11771
- ];
11772
- if (args.context) rgArgs.push("-C", String(args.context));
11773
- if (args.glob) rgArgs.push("--glob", args.glob);
11774
- rgArgs.push(args.pattern, r.resolved);
11775
-
11776
- return new Promise((resolve) => {
11777
- let output = "";
11778
- let killed = false;
11779
- const proc = spawnProc(rgPath, rgArgs, { timeout: 15000, windowsHide: true });
11780
-
11781
- proc.stdout.on("data", (chunk) => {
11782
- output += chunk.toString();
11783
- if (output.length > 65536) { // 64KB cap
11784
- killed = true;
11785
- proc.kill();
11786
- }
11787
- });
11788
- proc.stderr.on("data", () => {}); // ignore stderr
11789
-
11790
- proc.on("close", (code) => {
11791
- if (killed) {
11792
- resolve(mcpResult(output.slice(0, 65536) + "\n... (output truncated at 64KB)"));
11793
- } else if (code === 1) {
11794
- resolve(mcpResult("No matches found"));
11795
- } else if (output) {
11796
- // Make paths relative to mount
11797
- const mountNorm = r.mount.path.replace(/\\/g, "/");
11798
- const cleaned = output.replace(new RegExp(mountNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "/?", "g"), "");
11799
- resolve(mcpResult(cleaned));
11800
- } else {
11801
- resolve(mcpResult("No matches found"));
11802
- }
11803
- });
11804
-
11805
- proc.on("error", (err) => {
11806
- resolve(mcpError(`Grep failed: ${err.message}`));
11807
- });
11808
- });
11809
- }
11810
-
11811
- case "fs_glob": {
11812
- const basePath = args.path || ".";
11813
- const r = await resolveInMount(basePath, args.mount, vault);
11814
- if (r.error) return mcpError(r.error);
11815
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
11816
- try {
11817
- const matches = await fg(args.pattern, {
11818
- cwd: r.resolved,
11819
- dot: false,
11820
- onlyFiles: true,
11821
- ignore: ["**/node_modules/**", "**/.git/**"],
11822
- });
11823
- if (matches.length === 0) return mcpResult("No files matched");
11824
- return mcpResult(matches.sort().join("\n"));
11825
- } catch (err) {
11826
- return mcpError(`Glob failed: ${err.message}`);
11827
- }
11828
- }
11829
-
11830
- case "fs_delete": {
11831
- const r = await resolveInMount(args.path, args.mount, vault);
11832
- if (r.error) return mcpError(r.error);
11833
- if (!checkAccess(r.mount, "d")) return mcpError("Delete access denied on this mount");
11834
- try {
11835
- const s = await stat(r.resolved);
11836
- await rm(r.resolved, { recursive: s.isDirectory() });
11837
- return mcpResult(`Deleted: ${args.path}`);
11838
- } catch (err) {
11839
- if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
11840
- return mcpError(`Delete failed: ${err.message}`);
11841
- }
11842
- }
11843
-
11844
- case "fs_mkdir": {
11845
- const r = await resolveInMount(args.path, args.mount, vault);
11846
- if (r.error) return mcpError(r.error);
11847
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11848
- try {
11849
- await mkdir(r.resolved, { recursive: true });
11850
- return mcpResult(`Created: ${args.path}`);
11851
- } catch (err) {
11852
- return mcpError(`Mkdir failed: ${err.message}`);
11853
- }
11854
- }
11855
-
11856
- case "fs_edit": {
11857
- if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
11858
- return mcpError("old_string and new_string must be strings");
11859
- }
11860
- if (args.old_string === args.new_string) {
11861
- return mcpError("old_string and new_string must differ");
11862
- }
11863
- if (args.old_string.length === 0) {
11864
- return mcpError("old_string must not be empty");
11865
- }
11866
- const r = await resolveInMount(args.path, args.mount, vault);
11867
- if (r.error) return mcpError(r.error);
11868
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
11869
- try {
11870
- const current = await readFile(r.resolved, "utf8");
11871
- if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
11872
- return mcpError("Edit rejected: current file hash does not match expected_sha256");
11873
- }
11874
- const parts = current.split(args.old_string);
11875
- const occurrences = parts.length - 1;
11876
- if (occurrences === 0) {
11877
- return mcpError(`Edit failed: old_string not found in ${args.path}`);
11878
- }
11879
- if (occurrences > 1 && !args.replace_all) {
11880
- return mcpError(`Edit failed: old_string is not unique (${occurrences} matches in ${args.path}). Set replace_all=true to replace all, or provide more surrounding context.`);
11881
- }
11882
- const updated = args.replace_all
11883
- ? parts.join(args.new_string)
11884
- : current.replace(args.old_string, args.new_string);
11885
- await atomicWriteText(r.resolved, updated);
11886
- const info = await fileInfo(r.resolved, args.path);
11887
- return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
11888
- } catch (err) {
11889
- if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
11890
- return mcpError(`Edit failed: ${err.message}`);
11891
- }
11892
- }
11893
-
11894
- case "fs_move": {
11895
- const src = await resolveInMount(args.from, args.mount, vault);
11896
- if (src.error) return mcpError(src.error);
11897
- const dst = await resolveInMount(args.to, args.mount, vault);
11898
- if (dst.error) return mcpError(dst.error);
11899
- if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
11900
- return mcpError("Move requires write+delete access on this mount");
11901
- }
11902
- try {
11903
- await stat(src.resolved);
11904
- } catch (err) {
11905
- if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
11906
- return mcpError(`Stat failed: ${err.message}`);
11907
- }
11908
- let dstExists = false;
11909
- try {
11910
- await stat(dst.resolved);
11911
- dstExists = true;
11912
- } catch (err) {
11913
- if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
11914
- }
11915
- if (dstExists && !args.overwrite) {
11916
- return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
11917
- }
11918
- try {
11919
- await mkdir(path.dirname(dst.resolved), { recursive: true });
11920
- if (dstExists && args.overwrite) {
11921
- const dstStat = await stat(dst.resolved);
11922
- await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
11923
- }
11924
- try {
11925
- await rename(src.resolved, dst.resolved);
11926
- } catch (err) {
11927
- if (err.code === "EXDEV") {
11928
- await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
11929
- const srcStat = await stat(src.resolved);
11930
- await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
11931
- } else {
11932
- throw err;
11933
- }
11934
- }
11935
- return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
11936
- } catch (err) {
11937
- return mcpError(`Move failed: ${err.message}`);
11938
- }
11939
- }
11940
-
11941
- case "fs_copy": {
11942
- const src = await resolveInMount(args.from, args.mount, vault);
11943
- if (src.error) return mcpError(src.error);
11944
- const dst = await resolveInMount(args.to, args.mount, vault);
11945
- if (dst.error) return mcpError(dst.error);
11946
- if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
11947
- if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
11948
- let srcStat;
11949
- try {
11950
- srcStat = await stat(src.resolved);
11951
- } catch (err) {
11952
- if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
11953
- return mcpError(`Stat failed: ${err.message}`);
11954
- }
11955
- let dstExists = false;
11956
- try {
11957
- await stat(dst.resolved);
11958
- dstExists = true;
11959
- } catch (err) {
11960
- if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
11961
- }
11962
- if (dstExists && !args.overwrite) {
11963
- return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
11964
- }
11965
- try {
11966
- await mkdir(path.dirname(dst.resolved), { recursive: true });
11967
- await cp(src.resolved, dst.resolved, {
11968
- recursive: true,
11969
- errorOnExist: false,
11970
- force: !!args.overwrite,
11971
- });
11972
- return mcpResult(JSON.stringify({
11973
- status: "copied",
11974
- from: args.from,
11975
- to: args.to,
11976
- type: srcStat.isDirectory() ? "dir" : "file",
11977
- }, null, 2));
11978
- } catch (err) {
11979
- return mcpError(`Copy failed: ${err.message}`);
11980
- }
11981
- }
11982
-
11983
- case "fs_mounts": {
11984
- const { mounts, error } = await getFileserverMounts(vault);
11985
- if (error) return mcpError(error);
11986
- if (!mounts || mounts.length === 0) return mcpResult("No fileserver mounts configured. Create one:\n1. Use clauth dashboard or clauth_enable to add a service with key_type='fileserver'\n2. Set the secret value to JSON: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (add 'g' to allow the git verbs: fs_commit, fs_use_branch, fs_repo_status)");
11987
- // Decode the access string so callers know what's possible BEFORE trying —
11988
- // notably `git` (the git verbs require it; absent = explain how to enable).
11989
- const decorated = mounts.map((m) => {
11990
- const a = String(m.access || "");
11991
- return { ...m, can: { read: a.includes("r"), write: a.includes("w"), delete: a.includes("d"), git: a.includes("g") } };
11992
- });
11993
- return mcpResult(JSON.stringify(decorated, null, 2));
11994
- }
11995
-
11996
- case "fs_repo_status": {
11997
- const r = await resolveInMount(".", args.mount, vault);
11998
- if (r.error) return mcpError(r.error);
11999
- if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
12000
- try {
12001
- const { result, error } = await fsGit.repoStatus(r.resolved);
12002
- if (error) return mcpError(error);
12003
- return mcpResult(JSON.stringify(result, null, 2));
12004
- } catch (err) {
12005
- return mcpError(`Status failed: ${err.message}`);
12006
- }
12007
- }
12008
-
12009
- case "fs_use_branch": {
12010
- const r = await resolveInMount(".", args.mount, vault);
12011
- if (r.error) return mcpError(r.error);
12012
- if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
12013
- try {
12014
- const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
12015
- if (error) return mcpError(error);
12016
- return mcpResult(JSON.stringify(result, null, 2));
12017
- } catch (err) {
12018
- return mcpError(`Branch switch failed: ${err.message}`);
12019
- }
12020
- }
12021
-
12022
- case "fs_commit": {
12023
- const r = await resolveInMount(".", args.mount, vault);
12024
- if (r.error) return mcpError(r.error);
12025
- if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
12026
- const push = args.push !== false;
12027
- const dryRun = args.dry_run === true;
12028
- // Fetch the push token from the vault up front (commit happens first, so
12029
- // a missing token still preserves the local commit). Skipped on dry-run.
12030
- let token = null, tokenError = null;
12031
- if (push && !dryRun) {
12032
- const sec = await vaultRetrieveValue(vault, "github");
12033
- if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
12034
- else token = sec.value;
12035
- }
12036
- try {
12037
- const { result, error } = await fsGit.commit(r.resolved, {
12038
- message: args.message,
12039
- paths: args.paths,
12040
- push,
12041
- dryRun,
12042
- expectedHead: args.expected_head,
12043
- remote: args.remote,
12044
- token,
12045
- tokenError,
12046
- authorName: args.author_name,
12047
- authorEmail: args.author_email,
12048
- });
12049
- if (error) return mcpError(error);
12050
- return mcpResult(JSON.stringify(result, null, 2));
12051
- } catch (err) {
12052
- return mcpError(`Commit failed: ${err.message}`);
12053
- }
12054
- }
12055
-
12056
- case "fs_diff": {
12057
- const r = await resolveInMount(".", args.mount, vault);
12058
- if (r.error) return mcpError(r.error);
12059
- if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
12060
- try {
12061
- const { result, error } = await fsGit.diff(r.resolved, { paths: args.paths, ref: args.ref, staged: args.staged === true });
12062
- if (error) return mcpError(error);
12063
- return mcpResult(JSON.stringify(result, null, 2));
12064
- } catch (err) {
12065
- return mcpError(`Diff failed: ${err.message}`);
12066
- }
12067
- }
12068
11326
 
12069
11327
  case "call_agent": {
12070
11328
  const result = await runCallAgent(args || {});
@@ -12253,286 +11511,6 @@ async function handleMcpTool(vault, name, args) {
12253
11511
  return mcpResult(JSON.stringify(result));
12254
11512
  }
12255
11513
 
12256
- case "fs_exec": {
12257
- const r = await resolveInMount(args.cwd || ".", args.mount, vault);
12258
- if (r.error) return mcpError(r.error);
12259
- const EXEC_ALLOWLIST = [
12260
- "git","pnpm","npx","node","python","python3","rclone","bash",
12261
- "cat","grep","find","wc","sha256sum","date","echo","tsc","eslint",
12262
- "ping","where","which","ls","dir","head","tail","sort","uniq","diff","curl",
12263
- "pip","docker","docker-compose","pm2","cloudflared",
12264
- "ssh-keygen","tar","netstat",
12265
- "get-filehash","get-content","select-string","test-path",
12266
- "get-childitem","measure-object","certutil","pwsh",
12267
- "copy-item","move-item","remove-item","new-item",
12268
- "rename-item","set-content","add-content","out-file",
12269
- "convertto-json","convertfrom-json","select-xml",
12270
- "invoke-webrequest",
12271
- "format-table","format-list",
12272
- "sort-object","where-object","group-object",
12273
- "write-output","out-string",
12274
- "get-nettcpconnection","get-process","stop-process",
12275
- "get-service","get-eventlog","get-date",
12276
- "compress-archive","expand-archive",
12277
- ];
12278
- const EXEC_BLOCKED = ["invoke-expression","iex","start-process","set-executionpolicy","reg","regedit","format","shutdown","restart-computer","reboot","mkfs","dd","cmd","del"];
12279
- const MAX_STDOUT = 102400;
12280
- if (!Array.isArray(args.command) || args.command.length === 0) return mcpError("command must be a non-empty array");
12281
- const cmd0 = path.basename(args.command[0]).replace(/\.exe$/i, "").toLowerCase();
12282
- if (EXEC_BLOCKED.includes(cmd0)) return mcpError(`Blocked command: ${cmd0}`);
12283
- if (!EXEC_ALLOWLIST.includes(cmd0)) return mcpError(`Command not in allowlist: ${cmd0}. Allowed: ${EXEC_ALLOWLIST.join(", ")}`);
12284
- if (args.command.includes("--force") && cmd0 === "git" && args.command.includes("push")) return mcpError("Force-push blocked");
12285
- if (cmd0 === "git" && args.command.includes("push") && (args.command.includes("main") || args.command.includes("master"))) return mcpError("Push to main/master blocked");
12286
- const timeout = Math.min(Number(args.timeout_seconds || 30), 300) * 1000;
12287
- const startTime = Date.now();
12288
- try {
12289
- const { execFile, exec } = await import("child_process");
12290
- const result = await new Promise((resolve, reject) => {
12291
- const execEnv = { ...process.env, ...(args.env || {}) };
12292
- if (os.platform() === "win32") {
12293
- const extra = ["C:\\Program Files\\PowerShell\\7", "C:\\Program Files\\Git\\cmd", "C:\\Program Files\\Git\\bin", "C:\\Program Files\\nodejs", process.env.APPDATA ? process.env.APPDATA + "\\npm" : ""].filter(Boolean).join(";");
12294
- const pathKey = Object.keys(execEnv).find(k => k.toUpperCase() === "PATH") || "Path";
12295
- execEnv[pathKey] = extra + ";" + (execEnv[pathKey] || "");
12296
- }
12297
- const useShell = args.shell || os.platform() === "win32";
12298
- // Resolve the Windows shell to an ABSOLUTE path. A bare "pwsh.exe" relies on a
12299
- // PATH lookup, and the winget/Store (MSIX) build of PowerShell lives in a
12300
- // version-stamped dir (…\WindowsApps\Microsoft.PowerShell_<version>_…\pwsh.exe) —
12301
- // every Store auto-update renames that dir, breaking the lookup and taking fs_exec
12302
- // down with it. Prefer the stable MSI install (path never changes across updates),
12303
- // then any pwsh on PATH, then Windows PowerShell 5.1 as a guaranteed last resort.
12304
- const resolveWinShell = () => {
12305
- const candidates = [
12306
- "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
12307
- process.env.LOCALAPPDATA ? process.env.LOCALAPPDATA + "\\Microsoft\\WindowsApps\\pwsh.exe" : "",
12308
- (process.env.SystemRoot || "C:\\Windows") + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
12309
- ].filter(Boolean);
12310
- for (const c of candidates) { try { if (fs.existsSync(c)) return c; } catch {} }
12311
- return "pwsh.exe"; // last-ditch: bare name, PATH lookup
12312
- };
12313
- const shellBin = os.platform() === "win32" ? resolveWinShell() : true;
12314
- const opts = { cwd: r.resolved, timeout, maxBuffer: MAX_STDOUT, windowsHide: true, env: execEnv, shell: useShell ? shellBin : false };
12315
- const cb = (err, stdout, stderr) => {
12316
- const duration = Date.now() - startTime;
12317
- const truncated = (stdout?.length || 0) >= MAX_STDOUT || (stderr?.length || 0) >= MAX_STDOUT;
12318
- if (err && !err.killed) resolve({ stdout: stdout || "", stderr: stderr || err.message, exit_code: err.code || 1, duration_ms: duration, truncated });
12319
- else if (err?.killed) resolve({ stdout: stdout || "", stderr: "Process killed (timeout)", exit_code: 137, duration_ms: duration, truncated: true });
12320
- else resolve({ stdout: stdout || "", stderr: stderr || "", exit_code: 0, duration_ms: duration, truncated });
12321
- };
12322
- const psEscape = (s) => "'" + s.replace(/'/g, "''") + "'";
12323
- if (os.platform() === "win32") {
12324
- const psCmd = args.shell
12325
- ? args.command.join(" ")
12326
- : "& " + args.command.map(psEscape).join(" ");
12327
- exec(psCmd, opts, cb);
12328
- } else if (useShell) {
12329
- exec(args.command.join(" "), opts, cb);
12330
- } else {
12331
- execFile(args.command[0], args.command.slice(1), opts, cb);
12332
- }
12333
- });
12334
- const auditLine = JSON.stringify({ ts: new Date().toISOString(), mount: r.mount.name, command: args.command, cwd: args.cwd || ".", exit_code: result.exit_code, duration_ms: result.duration_ms }) + "\n";
12335
- try { await appendFile(path.join(os.tmpdir(), "fs-exec-audit.jsonl"), auditLine); } catch {}
12336
- return mcpResult(JSON.stringify(result));
12337
- } catch (err) {
12338
- return mcpError(`exec failed: ${err.message}`);
12339
- }
12340
- }
12341
-
12342
- case "fs_hash": {
12343
- const r = await resolveInMount(".", args.mount, vault);
12344
- if (r.error) return mcpError(r.error);
12345
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
12346
- if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
12347
- if (args.paths.length > 50) return mcpError("Max 50 paths per call");
12348
- const algo = args.algorithm || "sha256";
12349
- if (!["sha256", "md5", "sha1"].includes(algo)) return mcpError("algorithm must be sha256, md5, or sha1");
12350
- const results = [];
12351
- for (const p of args.paths) {
12352
- const fp = await resolveInMount(p, args.mount, vault);
12353
- if (fp.error) { results.push({ path: p, error: fp.error }); continue; }
12354
- try {
12355
- const content = await readFile(fp.resolved);
12356
- const hash = crypto.createHash(algo).update(content).digest("hex");
12357
- results.push({ path: p, hash, bytes: content.length });
12358
- } catch (err) {
12359
- results.push({ path: p, error: err.message });
12360
- }
12361
- }
12362
- return mcpResult(JSON.stringify({ results }));
12363
- }
12364
-
12365
- case "fs_cat_lines": {
12366
- const r = await resolveInMount(args.path, args.mount, vault);
12367
- if (r.error) return mcpError(r.error);
12368
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
12369
- try {
12370
- const content = await readFile(r.resolved, "utf8");
12371
- const allLines = content.split("\n");
12372
- const start = Math.max(1, Math.floor(args.start_line)) - 1;
12373
- const end = args.end_line ? Math.min(Math.floor(args.end_line), allLines.length) : allLines.length;
12374
- const selected = allLines.slice(start, end);
12375
- return mcpResult(JSON.stringify({ path: args.path, start: start + 1, end, total_lines: allLines.length, content: selected.join("\n") }));
12376
- } catch (err) {
12377
- return mcpError(`Read failed: ${err.message}`);
12378
- }
12379
- }
12380
-
12381
- case "fs_grep_json": {
12382
- const searchPath = args.path || ".";
12383
- const r = await resolveInMount(searchPath, args.mount, vault);
12384
- if (r.error) return mcpError(r.error);
12385
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
12386
- const maxResults = Math.min(Number(args.max_results || 50), 200);
12387
- const ctx = Math.min(Number(args.context_lines || 0), 10);
12388
- try {
12389
- const rgArgs = ["--json", "-e", args.pattern, "--max-count", String(maxResults)];
12390
- if (ctx > 0) rgArgs.push("-C", String(ctx));
12391
- if (args.glob) rgArgs.push("-g", args.glob);
12392
- rgArgs.push(r.resolved);
12393
- const raw = execSyncTop(`"${rgPath}" ${rgArgs.map(a => `"${a}"`).join(" ")}`, { encoding: "utf8", timeout: 30000, windowsHide: true, maxBuffer: 1024 * 1024 });
12394
- const matches = [];
12395
- for (const line of raw.split("\n").filter(Boolean)) {
12396
- try {
12397
- const obj = JSON.parse(line);
12398
- if (obj.type === "match") {
12399
- const rel = path.relative(r.resolved, obj.data.path.text).replace(/\\/g, "/");
12400
- matches.push({ file: rel, line: obj.data.line_number, match: obj.data.lines.text.trimEnd() });
12401
- }
12402
- } catch {}
12403
- }
12404
- return mcpResult(JSON.stringify({ matches, total_matches: matches.length, truncated: matches.length >= maxResults }));
12405
- } catch (err) {
12406
- if (err.status === 1) return mcpResult(JSON.stringify({ matches: [], total_matches: 0, truncated: false }));
12407
- return mcpError(`grep failed: ${err.message}`);
12408
- }
12409
- }
12410
-
12411
- case "fs_search": {
12412
- const searchBase = args.path || ".";
12413
- const r = await resolveInMount(searchBase, args.mount, vault);
12414
- if (r.error) return mcpError(r.error);
12415
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
12416
- const maxResults = Math.min(Number(args.max_results || 50), 200);
12417
- try {
12418
- const globPattern = args.query || (args.file_types ? `**/*{${args.file_types.join(",")}}` : "**/*");
12419
- const files = await fg(globPattern, { cwd: r.resolved, absolute: true, stats: true, dot: false, ignore: ["**/node_modules/**", "**/.next/**", "**/.git/**"] });
12420
- let results = [];
12421
- for (const f of files) {
12422
- const st = f.stats || {};
12423
- const mtime = st.mtime ? new Date(st.mtime) : null;
12424
- if (args.modified_after && mtime && mtime < new Date(args.modified_after)) continue;
12425
- if (args.modified_before && mtime && mtime > new Date(args.modified_before)) continue;
12426
- const rel = path.relative(r.resolved, f.path).replace(/\\/g, "/");
12427
- const entry = { path: rel, size: st.size || 0, modified: mtime?.toISOString() || null };
12428
- if (args.content_pattern) {
12429
- try {
12430
- const text = await readFile(f.path, "utf8");
12431
- const match = text.match(new RegExp(args.content_pattern));
12432
- if (!match) continue;
12433
- const lineIdx = text.substring(0, match.index).split("\n").length;
12434
- entry.match_line = lineIdx;
12435
- entry.match_text = match[0].slice(0, 200);
12436
- } catch { continue; }
12437
- }
12438
- results.push(entry);
12439
- if (results.length >= maxResults) break;
12440
- }
12441
- return mcpResult(JSON.stringify({ results, total: results.length }));
12442
- } catch (err) {
12443
- return mcpError(`search failed: ${err.message}`);
12444
- }
12445
- }
12446
-
12447
- case "fs_put": {
12448
- const r = await resolveInMount(args.path, args.mount, vault);
12449
- if (r.error) return mcpError(r.error);
12450
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied");
12451
- try {
12452
- const exists = await stat(r.resolved).then(() => true).catch(() => false);
12453
- if (exists && !args.overwrite) return mcpError(`File exists: ${args.path} (set overwrite=true)`);
12454
- const buf = Buffer.from(args.content_base64, "base64");
12455
- const dir = path.dirname(r.resolved);
12456
- await mkdir(dir, { recursive: true });
12457
- await atomicWriteText(r.resolved, buf);
12458
- const hash = crypto.createHash("sha256").update(buf).digest("hex");
12459
- return mcpResult(JSON.stringify({ path: args.path, bytes: buf.length, sha256: hash }));
12460
- } catch (err) {
12461
- return mcpError(`Put failed: ${err.message}`);
12462
- }
12463
- }
12464
-
12465
- case "fs_dav_setup": {
12466
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
12467
- const ttl = Math.min(Number(args.ttl_seconds || 14400), 86400);
12468
-
12469
- const startResult = await webdavService.ensureRunning(() => vault);
12470
- if (startResult.error) return mcpError(`WebDAV setup failed: ${startResult.error}`);
12471
-
12472
- const wdStatus = webdavService.getStatus();
12473
- if (wdStatus.status === "error") {
12474
- return mcpError(
12475
- `WebDAV child error: ${wdStatus.error}\n` +
12476
- `Restarts so far: ${wdStatus.restart_count}. Retry or check clauth logs.`
12477
- );
12478
- }
12479
-
12480
- // Fetch plaintext credential — the sandbox's own rclone obscures it in the script
12481
- let plainPass = "";
12482
- try {
12483
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
12484
- const cr = await api.retrieve(vault.password, vault.machineHash, token, timestamp, "webdav-claude");
12485
- if (cr.value) plainPass = cr.value;
12486
- } catch {}
12487
-
12488
- const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
12489
-
12490
- const setupScript = [
12491
- "#!/usr/bin/env bash",
12492
- "# WebDAV setup — generated by fs_dav_setup (clauth)",
12493
- `# Expires hint: ${expiresAt}`,
12494
- "set -e",
12495
- "",
12496
- "# Configure rclone WebDAV remote via env vars (no config file, no mount, no daemon)",
12497
- "export RCLONE_CONFIG_DAV_TYPE=webdav",
12498
- "export RCLONE_CONFIG_DAV_URL=https://dav.regendevcorp.com",
12499
- "export RCLONE_CONFIG_DAV_VENDOR=rclone",
12500
- "export RCLONE_CONFIG_DAV_USER=claude",
12501
- plainPass
12502
- ? `export RCLONE_CONFIG_DAV_PASS=$(rclone obscure "${plainPass}")`
12503
- : "# RCLONE_CONFIG_DAV_PASS — no credential found in vault",
12504
- "",
12505
- "# Verify connectivity",
12506
- "rclone lsd dav: --quiet",
12507
- 'echo "DAV ready — use rclone commands directly:"',
12508
- 'echo " rclone lsd dav:corpus/ # list dirs"',
12509
- 'echo " rclone cat dav:corpus/file.md # read file"',
12510
- 'echo " rclone rcat dav:dev/file.txt <<< content # write file"',
12511
- 'echo " rclone copy local/ dav:dev/path/ # upload"',
12512
- 'echo " rclone cat dav:file | grep pattern # search"',
12513
- ].join("\n");
12514
-
12515
- return mcpResult(JSON.stringify({
12516
- status: wdStatus.status,
12517
- url: "https://dav.regendevcorp.com",
12518
- setup_script: setupScript,
12519
- expires_at: expiresAt,
12520
- ttl_seconds: ttl,
12521
- webdav_user: "claude",
12522
- started_at: wdStatus.started_at,
12523
- mounts: (webdavService.loadConfig().upstreams || []).map(u => ({
12524
- rclone_path: `dav:${u.name}/`,
12525
- host_path: u.path,
12526
- description: u.name === "corpus" ? "Global corpus ($CORPUS_ROOT on host — Google Drive)" : `Local filesystem (${u.path} on host)`,
12527
- })),
12528
- deprecated_tools: ["fs_read", "fs_list", "fs_stat", "fs_edit", "fs_mkdir", "fs_use_branch"],
12529
- deprecated_note: "Use WebDAV (rclone cat/ls/lsd) instead — 37-56x faster. These tools remain as fallback.",
12530
- exec_enabled: true,
12531
- exec_allowlist: ["git", "pnpm", "npx", "node", "python3", "rclone", "bash", "cat", "grep", "find", "wc", "sha256sum", "tsc", "eslint"],
12532
- new_tools: ["fs_exec", "fs_hash", "fs_cat_lines", "fs_grep_json", "fs_search", "fs_put"],
12533
- note: "Run setup_script once per session to configure rclone env vars. Then use rclone commands: lsd (list), cat (read), rcat (write), copy (upload). Each command is stateless — no mount, no daemon, survives across turns. Use fs_exec for git/build/test commands on the host.",
12534
- }, null, 2));
12535
- }
12536
11514
 
12537
11515
  default:
12538
11516
  return mcpError(`Unknown tool: ${name}`);