@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.
@@ -0,0 +1,1055 @@
1
+ // cli/commands/serve/tools/fs.js
2
+ // Extracted from serve.js — all fs_* MCP tool handlers + fs-only helpers.
3
+ // Shared deps arrive via the `ctx` object; Node built-ins imported here.
4
+
5
+ import crypto from "crypto";
6
+ import fs from "fs";
7
+ import os from "os";
8
+ import path from "path";
9
+ import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
10
+ import fg from "fast-glob";
11
+ import { rgPath } from "@vscode/ripgrep";
12
+ import { execSync as execSyncTop, spawn as spawnProc, spawnSync } from "child_process";
13
+
14
+ // ── fs-only constants ──────────────────────────────────────────────
15
+
16
+ const FS_CACHE_TTL = 60000;
17
+ let _fsMountsCache = null;
18
+ let _fsMountsCacheTime = 0;
19
+
20
+ const FS_UPLOAD_SESSIONS = new Map();
21
+ const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
22
+ const FS_MAX_CHUNKS = 500;
23
+ const FS_MAX_CHUNK_BYTES = 128 * 1024;
24
+ const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
25
+ const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
26
+ "docs/",
27
+ ".rdc/plans/",
28
+ ".rdc/guides/",
29
+ ".claude/context/",
30
+ ".claude/rules/",
31
+ ".rdc/relay/from-claude-ai/",
32
+ ];
33
+
34
+ // ── fs-only helpers ────────────────────────────────────────────────
35
+
36
+ async function getFileserverMounts(vault, deriveToken, api) {
37
+ if (!vault.password) return { error: "Vault is locked — unlock first" };
38
+ const now = Date.now();
39
+ if (_fsMountsCache && now - _fsMountsCacheTime < FS_CACHE_TTL) return { mounts: _fsMountsCache };
40
+
41
+ try {
42
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
43
+ const result = await api.status(vault.password, vault.machineHash, token, timestamp);
44
+ if (result.error) return { error: result.error };
45
+ const mounts = [];
46
+ for (const s of (result.services || [])) {
47
+ if (s.key_type === "fileserver" && s.enabled) {
48
+ try {
49
+ const { token: t2, timestamp: ts2 } = deriveToken(vault.password, vault.machineHash);
50
+ const secret = await api.retrieve(vault.password, vault.machineHash, t2, ts2, s.name);
51
+ if (secret.value) {
52
+ const config = JSON.parse(secret.value);
53
+ mounts.push({ name: s.name, path: config.path, access: config.access || "r" });
54
+ }
55
+ } catch {}
56
+ }
57
+ }
58
+ _fsMountsCache = mounts;
59
+ _fsMountsCacheTime = now;
60
+ return { mounts };
61
+ } catch (err) {
62
+ return { error: `Mount lookup failed: ${err.message}` };
63
+ }
64
+ }
65
+
66
+ async function resolveInMount(requestedPath, mountName, vault, deriveToken, api) {
67
+ const { mounts, error } = await getFileserverMounts(vault, deriveToken, api);
68
+ if (error) return { error };
69
+ 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)" };
70
+ const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
71
+ if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
72
+ if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
73
+ const resolved = path.resolve(mount.path, requestedPath);
74
+ const normalized = path.normalize(resolved);
75
+ const relative = path.relative(path.normalize(mount.path), normalized);
76
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
77
+ return { error: `Path escapes mount: ${requestedPath}` };
78
+ }
79
+ return { resolved: normalized, mount };
80
+ }
81
+
82
+ function checkAccess(mount, flag) {
83
+ return mount.access.includes(flag);
84
+ }
85
+
86
+ function sha256Hex(value) {
87
+ return crypto.createHash("sha256").update(value).digest("hex");
88
+ }
89
+
90
+ async function atomicWriteText(filePath, content) {
91
+ await mkdir(path.dirname(filePath), { recursive: true });
92
+ const tempPath = path.join(
93
+ path.dirname(filePath),
94
+ `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
95
+ );
96
+ await writeFile(tempPath, content, "utf8");
97
+ await rename(tempPath, filePath);
98
+ }
99
+
100
+ async function fileInfo(filePath, requestedPath) {
101
+ const s = await stat(filePath);
102
+ const info = {
103
+ path: requestedPath,
104
+ type: s.isDirectory() ? "dir" : "file",
105
+ size: s.size,
106
+ modified: s.mtime.toISOString(),
107
+ };
108
+ if (s.isFile()) {
109
+ const content = await readFile(filePath);
110
+ info.sha256 = sha256Hex(content);
111
+ }
112
+ return info;
113
+ }
114
+
115
+ function cleanupFsUploadSessions() {
116
+ const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
117
+ for (const [id, session] of FS_UPLOAD_SESSIONS) {
118
+ if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
119
+ }
120
+ }
121
+
122
+ function runGit(cwd, args, opts = {}) {
123
+ const res = spawnSync("git", args, {
124
+ cwd,
125
+ encoding: "utf8",
126
+ windowsHide: true,
127
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
128
+ });
129
+ if (res.status !== 0) {
130
+ const detail = (res.stderr || res.stdout || "").trim();
131
+ throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
132
+ }
133
+ return (res.stdout || "").trim();
134
+ }
135
+
136
+ function runGitRaw(cwd, args, opts = {}) {
137
+ const res = spawnSync("git", args, {
138
+ cwd,
139
+ encoding: "buffer",
140
+ windowsHide: true,
141
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
142
+ });
143
+ if (res.status !== 0) {
144
+ const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
145
+ throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
146
+ }
147
+ return res.stdout || Buffer.alloc(0);
148
+ }
149
+
150
+ async function vaultRetrieveValue(vault, service, deriveToken, api) {
151
+ if (!vault.password) return { error: "locked" };
152
+ if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
153
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
154
+ return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
155
+ }
156
+
157
+ function normalizeRepoPath(p) {
158
+ if (!p || typeof p !== "string") return null;
159
+ const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
160
+ const parts = normalized.split("/").filter(Boolean);
161
+ if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
162
+ return parts.join("/");
163
+ }
164
+
165
+ function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
166
+ return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
167
+ }
168
+
169
+ // ── main dispatch ──────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Handle any fs_* MCP tool call.
173
+ * @param {string} name - tool name (e.g. "fs_read")
174
+ * @param {object} args - tool arguments
175
+ * @param {object} ctx - shared context from serve.js:
176
+ * { vault, mcpResult, mcpError, deriveToken, api, webdavService, fsGit }
177
+ * @returns {object|null} MCP response, or null if name isn't an fs_ tool
178
+ */
179
+ export async function handleFsTool(name, args, ctx) {
180
+ const { vault, mcpResult, mcpError, deriveToken, api, webdavService, fsGit } = ctx;
181
+
182
+ const resolve = (p, mount) => resolveInMount(p, mount, vault, deriveToken, api);
183
+
184
+ switch (name) {
185
+ case "fs_read": {
186
+ const r = await resolve(args.path, args.mount);
187
+ if (r.error) return mcpError(r.error);
188
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
189
+ try {
190
+ const content = await readFile(r.resolved, "utf8");
191
+ const lines = content.split("\n");
192
+ const offset = args.offset || 0;
193
+ const limit = args.limit || 500;
194
+ const slice = lines.slice(offset, offset + limit);
195
+ const numbered = slice.map((line, i) => `${offset + i + 1}\t${line}`).join("\n");
196
+ const header = `${r.resolved} (${lines.length} lines${offset > 0 ? `, showing ${offset + 1}-${Math.min(offset + limit, lines.length)}` : ""})`;
197
+ return mcpResult(`${header}\n${numbered}`);
198
+ } catch (err) {
199
+ if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
200
+ return mcpError(`Read failed: ${err.message}`);
201
+ }
202
+ }
203
+
204
+ case "fs_write": {
205
+ const r = await resolve(args.path, args.mount);
206
+ if (r.error) return mcpError(r.error);
207
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
208
+ try {
209
+ await atomicWriteText(r.resolved, args.content);
210
+ return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
211
+ } catch (err) {
212
+ return mcpError(`Write failed: ${err.message}`);
213
+ }
214
+ }
215
+
216
+ case "fs_stat": {
217
+ const r = await resolve(args.path, args.mount);
218
+ if (r.error) return mcpError(r.error);
219
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
220
+ try {
221
+ return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
222
+ } catch (err) {
223
+ if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
224
+ return mcpError(`Stat failed: ${err.message}`);
225
+ }
226
+ }
227
+
228
+ case "fs_append": {
229
+ const r = await resolve(args.path, args.mount);
230
+ if (r.error) return mcpError(r.error);
231
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
232
+ try {
233
+ try {
234
+ const current = await readFile(r.resolved);
235
+ if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
236
+ return mcpError("Append rejected: current file hash does not match expected_sha256");
237
+ }
238
+ } catch (err) {
239
+ if (err.code !== "ENOENT") throw err;
240
+ if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
241
+ }
242
+ await mkdir(path.dirname(r.resolved), { recursive: true });
243
+ await appendFile(r.resolved, args.content, "utf8");
244
+ const info = await fileInfo(r.resolved, args.path);
245
+ return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
246
+ } catch (err) {
247
+ return mcpError(`Append failed: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ case "fs_write_chunk": {
252
+ cleanupFsUploadSessions();
253
+ const { upload_id, chunk_index, total_chunks, content } = args;
254
+ const index = Number(chunk_index);
255
+ const total = Number(total_chunks);
256
+ if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
257
+ return mcpError("Invalid chunk_index/total_chunks");
258
+ }
259
+ if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
260
+ if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
261
+ return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
262
+ }
263
+
264
+ const r = await resolve(args.path, args.mount);
265
+ if (r.error) return mcpError(r.error);
266
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
267
+
268
+ const key = `${r.mount.name}:${args.path}:${upload_id}`;
269
+ let session = FS_UPLOAD_SESSIONS.get(key);
270
+ if (!session) {
271
+ session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
272
+ FS_UPLOAD_SESSIONS.set(key, session);
273
+ }
274
+ if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
275
+ return mcpError("Upload id collision: path or total_chunks differs from existing session");
276
+ }
277
+ if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
278
+ return mcpError("Upload id collision: expected_sha256 differs from existing session");
279
+ }
280
+
281
+ session.chunks.set(index, content);
282
+ session.updatedAt = Date.now();
283
+
284
+ if (session.chunks.size < total) {
285
+ return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
286
+ }
287
+
288
+ const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
289
+ const actualSha = sha256Hex(assembled);
290
+ if (session.expectedSha256 && actualSha !== session.expectedSha256) {
291
+ FS_UPLOAD_SESSIONS.delete(key);
292
+ return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
293
+ }
294
+
295
+ try {
296
+ await atomicWriteText(r.resolved, assembled);
297
+ FS_UPLOAD_SESSIONS.delete(key);
298
+ return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
299
+ } catch (err) {
300
+ return mcpError(`Chunked write failed: ${err.message}`);
301
+ }
302
+ }
303
+
304
+ case "fs_ingest_url": {
305
+ const r = await resolve(args.path, args.mount);
306
+ if (r.error) return mcpError(r.error);
307
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
308
+
309
+ let url;
310
+ try {
311
+ url = new URL(args.url);
312
+ } catch {
313
+ return mcpError("Invalid URL");
314
+ }
315
+ if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
316
+
317
+ const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
318
+ try {
319
+ const response = await fetch(url, { redirect: "follow" });
320
+ if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
321
+ const length = Number(response.headers.get("content-length") || 0);
322
+ if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
323
+
324
+ const reader = response.body?.getReader();
325
+ if (!reader) return mcpError("Fetch failed: response body is not readable");
326
+
327
+ let received = 0;
328
+ const chunks = [];
329
+ while (true) {
330
+ const { done, value } = await reader.read();
331
+ if (done) break;
332
+ received += value.byteLength;
333
+ if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
334
+ chunks.push(Buffer.from(value));
335
+ }
336
+
337
+ const content = Buffer.concat(chunks).toString("utf8");
338
+ const actualSha = sha256Hex(content);
339
+ if (args.expected_sha256 && actualSha !== args.expected_sha256) {
340
+ return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
341
+ }
342
+ await atomicWriteText(r.resolved, content);
343
+ return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
344
+ } catch (err) {
345
+ return mcpError(`Ingest failed: ${err.message}`);
346
+ }
347
+ }
348
+
349
+ case "fs_import_git_files": {
350
+ if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
351
+ if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
352
+
353
+ const r = await resolve(".", args.mount);
354
+ if (r.error) return mcpError(r.error);
355
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
356
+
357
+ const repoRoot = r.resolved;
358
+ const remote = args.remote || "origin";
359
+ const mode = args.mode || "new_only";
360
+ const doCommit = args.commit === true;
361
+ const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
362
+ ? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
363
+ : FS_GIT_IMPORT_ALLOWED_PREFIXES;
364
+
365
+ try {
366
+ const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
367
+ if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
368
+ return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
369
+ }
370
+
371
+ const normalizedPaths = [];
372
+ for (const rawPath of args.paths) {
373
+ const normalized = normalizeRepoPath(rawPath);
374
+ if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
375
+ if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
376
+ normalizedPaths.push(normalized);
377
+ }
378
+
379
+ if (mode === "new_only") {
380
+ for (const rel of normalizedPaths) {
381
+ const localPath = path.join(repoRoot, rel);
382
+ try {
383
+ await stat(localPath);
384
+ return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
385
+ } catch (err) {
386
+ if (err.code !== "ENOENT") throw err;
387
+ }
388
+ }
389
+ }
390
+
391
+ if (doCommit) {
392
+ const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
393
+ if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
394
+ if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
395
+ }
396
+
397
+ runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
398
+ const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
399
+
400
+ for (const rel of normalizedPaths) {
401
+ runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
402
+ }
403
+
404
+ runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
405
+
406
+ const imported = [];
407
+ for (const rel of normalizedPaths) {
408
+ const localPath = path.join(repoRoot, rel);
409
+ const info = await fileInfo(localPath, rel);
410
+ const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
411
+ const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
412
+ imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
413
+ }
414
+
415
+ let localCommit = null;
416
+ if (doCommit) {
417
+ runGit(repoRoot, ["add", "--", ...normalizedPaths]);
418
+ const body = [
419
+ args.message.trim(),
420
+ "",
421
+ "Imported from Claude.ai GitHub upload.",
422
+ "",
423
+ `Source remote: ${remote}`,
424
+ `Source ref: ${args.ref}`,
425
+ `Source commit: ${sourceCommit}`,
426
+ "",
427
+ "Paths:",
428
+ ...normalizedPaths.map((p) => `- ${p}`),
429
+ ].join("\n");
430
+ runGit(repoRoot, ["commit", "-m", body]);
431
+ localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
432
+ }
433
+
434
+ return mcpResult(JSON.stringify({
435
+ status: "ok",
436
+ mode,
437
+ committed: doCommit,
438
+ source_commit: sourceCommit,
439
+ local_commit: localCommit,
440
+ imported,
441
+ }, null, 2));
442
+ } catch (err) {
443
+ return mcpError(`Git import failed: ${err.message}`);
444
+ }
445
+ }
446
+
447
+ case "fs_list": {
448
+ const dirPath = args.path || ".";
449
+ const r = await resolve(dirPath, args.mount);
450
+ if (r.error) return mcpError(r.error);
451
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
452
+ try {
453
+ const entries = await readdir(r.resolved, { withFileTypes: true });
454
+ const results = [];
455
+ for (const entry of entries) {
456
+ try {
457
+ const s = await stat(path.join(r.resolved, entry.name));
458
+ results.push({
459
+ name: entry.name,
460
+ type: entry.isDirectory() ? "dir" : "file",
461
+ size: s.size,
462
+ modified: s.mtime.toISOString(),
463
+ });
464
+ } catch {
465
+ results.push({ name: entry.name, type: entry.isDirectory() ? "dir" : "file" });
466
+ }
467
+ }
468
+ return mcpResult(JSON.stringify(results, null, 2));
469
+ } catch (err) {
470
+ if (err.code === "ENOENT") return mcpError(`Directory not found: ${dirPath}`);
471
+ return mcpError(`List failed: ${err.message}`);
472
+ }
473
+ }
474
+
475
+ case "fs_grep": {
476
+ const searchPath = args.path || ".";
477
+ const r = await resolve(searchPath, args.mount);
478
+ if (r.error) return mcpError(r.error);
479
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
480
+
481
+ const maxResults = args.max_results || 50;
482
+ const rgArgs = [
483
+ "--no-heading", "--line-number", "--color", "never",
484
+ "--max-count", String(maxResults),
485
+ ];
486
+ if (args.context) rgArgs.push("-C", String(args.context));
487
+ if (args.glob) rgArgs.push("--glob", args.glob);
488
+ rgArgs.push(args.pattern, r.resolved);
489
+
490
+ return new Promise((resolvePromise) => {
491
+ let output = "";
492
+ let killed = false;
493
+ const proc = spawnProc(rgPath, rgArgs, { timeout: 15000, windowsHide: true });
494
+
495
+ proc.stdout.on("data", (chunk) => {
496
+ output += chunk.toString();
497
+ if (output.length > 65536) {
498
+ killed = true;
499
+ proc.kill();
500
+ }
501
+ });
502
+ proc.stderr.on("data", () => {});
503
+
504
+ proc.on("close", (code) => {
505
+ if (killed) {
506
+ resolvePromise(mcpResult(output.slice(0, 65536) + "\n... (output truncated at 64KB)"));
507
+ } else if (code === 1) {
508
+ resolvePromise(mcpResult("No matches found"));
509
+ } else if (output) {
510
+ const mountNorm = r.mount.path.replace(/\\/g, "/");
511
+ const cleaned = output.replace(new RegExp(mountNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "/?", "g"), "");
512
+ resolvePromise(mcpResult(cleaned));
513
+ } else {
514
+ resolvePromise(mcpResult("No matches found"));
515
+ }
516
+ });
517
+
518
+ proc.on("error", (err) => {
519
+ resolvePromise(mcpError(`Grep failed: ${err.message}`));
520
+ });
521
+ });
522
+ }
523
+
524
+ case "fs_glob": {
525
+ const basePath = args.path || ".";
526
+ const r = await resolve(basePath, args.mount);
527
+ if (r.error) return mcpError(r.error);
528
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
529
+ try {
530
+ const matches = await fg(args.pattern, {
531
+ cwd: r.resolved,
532
+ dot: false,
533
+ onlyFiles: true,
534
+ ignore: ["**/node_modules/**", "**/.git/**"],
535
+ });
536
+ if (matches.length === 0) return mcpResult("No files matched");
537
+ return mcpResult(matches.sort().join("\n"));
538
+ } catch (err) {
539
+ return mcpError(`Glob failed: ${err.message}`);
540
+ }
541
+ }
542
+
543
+ case "fs_delete": {
544
+ const r = await resolve(args.path, args.mount);
545
+ if (r.error) return mcpError(r.error);
546
+ if (!checkAccess(r.mount, "d")) return mcpError("Delete access denied on this mount");
547
+ try {
548
+ const s = await stat(r.resolved);
549
+ await rm(r.resolved, { recursive: s.isDirectory() });
550
+ return mcpResult(`Deleted: ${args.path}`);
551
+ } catch (err) {
552
+ if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
553
+ return mcpError(`Delete failed: ${err.message}`);
554
+ }
555
+ }
556
+
557
+ case "fs_mkdir": {
558
+ const r = await resolve(args.path, args.mount);
559
+ if (r.error) return mcpError(r.error);
560
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
561
+ try {
562
+ await mkdir(r.resolved, { recursive: true });
563
+ return mcpResult(`Created: ${args.path}`);
564
+ } catch (err) {
565
+ return mcpError(`Mkdir failed: ${err.message}`);
566
+ }
567
+ }
568
+
569
+ case "fs_edit": {
570
+ if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
571
+ return mcpError("old_string and new_string must be strings");
572
+ }
573
+ if (args.old_string === args.new_string) {
574
+ return mcpError("old_string and new_string must differ");
575
+ }
576
+ if (args.old_string.length === 0) {
577
+ return mcpError("old_string must not be empty");
578
+ }
579
+ const r = await resolve(args.path, args.mount);
580
+ if (r.error) return mcpError(r.error);
581
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
582
+ try {
583
+ const current = await readFile(r.resolved, "utf8");
584
+ if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
585
+ return mcpError("Edit rejected: current file hash does not match expected_sha256");
586
+ }
587
+ const parts = current.split(args.old_string);
588
+ const occurrences = parts.length - 1;
589
+ if (occurrences === 0) {
590
+ return mcpError(`Edit failed: old_string not found in ${args.path}`);
591
+ }
592
+ if (occurrences > 1 && !args.replace_all) {
593
+ 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.`);
594
+ }
595
+ const updated = args.replace_all
596
+ ? parts.join(args.new_string)
597
+ : current.replace(args.old_string, args.new_string);
598
+ await atomicWriteText(r.resolved, updated);
599
+ const info = await fileInfo(r.resolved, args.path);
600
+ return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
601
+ } catch (err) {
602
+ if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
603
+ return mcpError(`Edit failed: ${err.message}`);
604
+ }
605
+ }
606
+
607
+ case "fs_move": {
608
+ const src = await resolve(args.from, args.mount);
609
+ if (src.error) return mcpError(src.error);
610
+ const dst = await resolve(args.to, args.mount);
611
+ if (dst.error) return mcpError(dst.error);
612
+ if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
613
+ return mcpError("Move requires write+delete access on this mount");
614
+ }
615
+ try {
616
+ await stat(src.resolved);
617
+ } catch (err) {
618
+ if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
619
+ return mcpError(`Stat failed: ${err.message}`);
620
+ }
621
+ let dstExists = false;
622
+ try {
623
+ await stat(dst.resolved);
624
+ dstExists = true;
625
+ } catch (err) {
626
+ if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
627
+ }
628
+ if (dstExists && !args.overwrite) {
629
+ return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
630
+ }
631
+ try {
632
+ await mkdir(path.dirname(dst.resolved), { recursive: true });
633
+ if (dstExists && args.overwrite) {
634
+ const dstStat = await stat(dst.resolved);
635
+ await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
636
+ }
637
+ try {
638
+ await rename(src.resolved, dst.resolved);
639
+ } catch (err) {
640
+ if (err.code === "EXDEV") {
641
+ await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
642
+ const srcStat = await stat(src.resolved);
643
+ await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
644
+ } else {
645
+ throw err;
646
+ }
647
+ }
648
+ return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
649
+ } catch (err) {
650
+ return mcpError(`Move failed: ${err.message}`);
651
+ }
652
+ }
653
+
654
+ case "fs_copy": {
655
+ const src = await resolve(args.from, args.mount);
656
+ if (src.error) return mcpError(src.error);
657
+ const dst = await resolve(args.to, args.mount);
658
+ if (dst.error) return mcpError(dst.error);
659
+ if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
660
+ if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
661
+ let srcStat;
662
+ try {
663
+ srcStat = await stat(src.resolved);
664
+ } catch (err) {
665
+ if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
666
+ return mcpError(`Stat failed: ${err.message}`);
667
+ }
668
+ let dstExists = false;
669
+ try {
670
+ await stat(dst.resolved);
671
+ dstExists = true;
672
+ } catch (err) {
673
+ if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
674
+ }
675
+ if (dstExists && !args.overwrite) {
676
+ return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
677
+ }
678
+ try {
679
+ await mkdir(path.dirname(dst.resolved), { recursive: true });
680
+ await cp(src.resolved, dst.resolved, {
681
+ recursive: true,
682
+ errorOnExist: false,
683
+ force: !!args.overwrite,
684
+ });
685
+ return mcpResult(JSON.stringify({
686
+ status: "copied",
687
+ from: args.from,
688
+ to: args.to,
689
+ type: srcStat.isDirectory() ? "dir" : "file",
690
+ }, null, 2));
691
+ } catch (err) {
692
+ return mcpError(`Copy failed: ${err.message}`);
693
+ }
694
+ }
695
+
696
+ case "fs_mounts": {
697
+ const { mounts, error } = await getFileserverMounts(vault, deriveToken, api);
698
+ if (error) return mcpError(error);
699
+ 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)");
700
+ const decorated = mounts.map((m) => {
701
+ const a = String(m.access || "");
702
+ return { ...m, can: { read: a.includes("r"), write: a.includes("w"), delete: a.includes("d"), git: a.includes("g") } };
703
+ });
704
+ return mcpResult(JSON.stringify(decorated, null, 2));
705
+ }
706
+
707
+ case "fs_repo_status": {
708
+ const r = await resolve(".", args.mount);
709
+ if (r.error) return mcpError(r.error);
710
+ 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.");
711
+ try {
712
+ const { result, error } = await fsGit.repoStatus(r.resolved);
713
+ if (error) return mcpError(error);
714
+ return mcpResult(JSON.stringify(result, null, 2));
715
+ } catch (err) {
716
+ return mcpError(`Status failed: ${err.message}`);
717
+ }
718
+ }
719
+
720
+ case "fs_use_branch": {
721
+ const r = await resolve(".", args.mount);
722
+ if (r.error) return mcpError(r.error);
723
+ 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.");
724
+ try {
725
+ const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
726
+ if (error) return mcpError(error);
727
+ return mcpResult(JSON.stringify(result, null, 2));
728
+ } catch (err) {
729
+ return mcpError(`Branch switch failed: ${err.message}`);
730
+ }
731
+ }
732
+
733
+ case "fs_commit": {
734
+ const r = await resolve(".", args.mount);
735
+ if (r.error) return mcpError(r.error);
736
+ 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.");
737
+ const push = args.push !== false;
738
+ const dryRun = args.dry_run === true;
739
+ let token = null, tokenError = null;
740
+ if (push && !dryRun) {
741
+ const sec = await vaultRetrieveValue(vault, "github", deriveToken, api);
742
+ if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
743
+ else token = sec.value;
744
+ }
745
+ try {
746
+ const { result, error } = await fsGit.commit(r.resolved, {
747
+ message: args.message,
748
+ paths: args.paths,
749
+ push,
750
+ dryRun,
751
+ expectedHead: args.expected_head,
752
+ remote: args.remote,
753
+ token,
754
+ tokenError,
755
+ authorName: args.author_name,
756
+ authorEmail: args.author_email,
757
+ });
758
+ if (error) return mcpError(error);
759
+ return mcpResult(JSON.stringify(result, null, 2));
760
+ } catch (err) {
761
+ return mcpError(`Commit failed: ${err.message}`);
762
+ }
763
+ }
764
+
765
+ case "fs_diff": {
766
+ const r = await resolve(".", args.mount);
767
+ if (r.error) return mcpError(r.error);
768
+ 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.");
769
+ try {
770
+ const { result, error } = await fsGit.diff(r.resolved, { paths: args.paths, ref: args.ref, staged: args.staged === true });
771
+ if (error) return mcpError(error);
772
+ return mcpResult(JSON.stringify(result, null, 2));
773
+ } catch (err) {
774
+ return mcpError(`Diff failed: ${err.message}`);
775
+ }
776
+ }
777
+
778
+ case "fs_exec": {
779
+ const r = await resolve(args.cwd || ".", args.mount);
780
+ if (r.error) return mcpError(r.error);
781
+ const EXEC_ALLOWLIST = [
782
+ "git","pnpm","npx","node","python","python3","rclone","bash",
783
+ "cat","grep","find","wc","sha256sum","date","echo","tsc","eslint",
784
+ "ping","where","which","ls","dir","head","tail","sort","uniq","diff","curl",
785
+ "pip","docker","docker-compose","pm2","cloudflared",
786
+ "ssh-keygen","tar","netstat",
787
+ "get-filehash","get-content","select-string","test-path",
788
+ "get-childitem","measure-object","certutil","pwsh",
789
+ "copy-item","move-item","remove-item","new-item",
790
+ "rename-item","set-content","add-content","out-file",
791
+ "convertto-json","convertfrom-json","select-xml",
792
+ "invoke-webrequest",
793
+ "format-table","format-list",
794
+ "sort-object","where-object","group-object",
795
+ "write-output","out-string",
796
+ "get-nettcpconnection","get-process","stop-process",
797
+ "get-service","get-eventlog","get-date",
798
+ "compress-archive","expand-archive",
799
+ ];
800
+ const EXEC_BLOCKED = ["invoke-expression","iex","start-process","set-executionpolicy","reg","regedit","format","shutdown","restart-computer","reboot","mkfs","dd","cmd","del"];
801
+ const MAX_STDOUT = 102400;
802
+ if (!Array.isArray(args.command) || args.command.length === 0) return mcpError("command must be a non-empty array");
803
+ const cmd0 = path.basename(args.command[0]).replace(/\.exe$/i, "").toLowerCase();
804
+ if (EXEC_BLOCKED.includes(cmd0)) return mcpError(`Blocked command: ${cmd0}`);
805
+ if (!EXEC_ALLOWLIST.includes(cmd0)) return mcpError(`Command not in allowlist: ${cmd0}. Allowed: ${EXEC_ALLOWLIST.join(", ")}`);
806
+ if (args.command.includes("--force") && cmd0 === "git" && args.command.includes("push")) return mcpError("Force-push blocked");
807
+ if (cmd0 === "git" && args.command.includes("push") && (args.command.includes("main") || args.command.includes("master"))) return mcpError("Push to main/master blocked");
808
+ const timeout = Math.min(Number(args.timeout_seconds || 30), 300) * 1000;
809
+ const startTime = Date.now();
810
+ try {
811
+ const { execFile: execFileChild, exec: execChild } = await import("child_process");
812
+ const result = await new Promise((resolveExec, reject) => {
813
+ const execEnv = { ...process.env, ...(args.env || {}) };
814
+ if (os.platform() === "win32") {
815
+ 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(";");
816
+ const pathKey = Object.keys(execEnv).find(k => k.toUpperCase() === "PATH") || "Path";
817
+ execEnv[pathKey] = extra + ";" + (execEnv[pathKey] || "");
818
+ }
819
+ const useShell = args.shell || os.platform() === "win32";
820
+ const resolveWinShell = () => {
821
+ const candidates = [
822
+ "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
823
+ process.env.LOCALAPPDATA ? process.env.LOCALAPPDATA + "\\Microsoft\\WindowsApps\\pwsh.exe" : "",
824
+ (process.env.SystemRoot || "C:\\Windows") + "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
825
+ ].filter(Boolean);
826
+ for (const c of candidates) { try { if (fs.existsSync(c)) return c; } catch {} }
827
+ return "pwsh.exe";
828
+ };
829
+ const shellBin = os.platform() === "win32" ? resolveWinShell() : true;
830
+ const opts = { cwd: r.resolved, timeout, maxBuffer: MAX_STDOUT, windowsHide: true, env: execEnv, shell: useShell ? shellBin : false };
831
+ const cb = (err, stdout, stderr) => {
832
+ const duration = Date.now() - startTime;
833
+ const truncated = (stdout?.length || 0) >= MAX_STDOUT || (stderr?.length || 0) >= MAX_STDOUT;
834
+ if (err && !err.killed) resolveExec({ stdout: stdout || "", stderr: stderr || err.message, exit_code: err.code || 1, duration_ms: duration, truncated });
835
+ else if (err?.killed) resolveExec({ stdout: stdout || "", stderr: "Process killed (timeout)", exit_code: 137, duration_ms: duration, truncated: true });
836
+ else resolveExec({ stdout: stdout || "", stderr: stderr || "", exit_code: 0, duration_ms: duration, truncated });
837
+ };
838
+ const psEscape = (s) => "'" + s.replace(/'/g, "''") + "'";
839
+ if (os.platform() === "win32") {
840
+ const psCmd = args.shell
841
+ ? args.command.join(" ")
842
+ : "& " + args.command.map(psEscape).join(" ");
843
+ execChild(psCmd, opts, cb);
844
+ } else if (useShell) {
845
+ execChild(args.command.join(" "), opts, cb);
846
+ } else {
847
+ execFileChild(args.command[0], args.command.slice(1), opts, cb);
848
+ }
849
+ });
850
+ 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";
851
+ try { await appendFile(path.join(os.tmpdir(), "fs-exec-audit.jsonl"), auditLine); } catch {}
852
+ return mcpResult(JSON.stringify(result));
853
+ } catch (err) {
854
+ return mcpError(`exec failed: ${err.message}`);
855
+ }
856
+ }
857
+
858
+ case "fs_hash": {
859
+ const r = await resolve(".", args.mount);
860
+ if (r.error) return mcpError(r.error);
861
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
862
+ if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
863
+ if (args.paths.length > 50) return mcpError("Max 50 paths per call");
864
+ const algo = args.algorithm || "sha256";
865
+ if (!["sha256", "md5", "sha1"].includes(algo)) return mcpError("algorithm must be sha256, md5, or sha1");
866
+ const results = [];
867
+ for (const p of args.paths) {
868
+ const fp = await resolve(p, args.mount);
869
+ if (fp.error) { results.push({ path: p, error: fp.error }); continue; }
870
+ try {
871
+ const content = await readFile(fp.resolved);
872
+ const hash = crypto.createHash(algo).update(content).digest("hex");
873
+ results.push({ path: p, hash, bytes: content.length });
874
+ } catch (err) {
875
+ results.push({ path: p, error: err.message });
876
+ }
877
+ }
878
+ return mcpResult(JSON.stringify({ results }));
879
+ }
880
+
881
+ case "fs_cat_lines": {
882
+ const r = await resolve(args.path, args.mount);
883
+ if (r.error) return mcpError(r.error);
884
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
885
+ try {
886
+ const content = await readFile(r.resolved, "utf8");
887
+ const allLines = content.split("\n");
888
+ const start = Math.max(1, Math.floor(args.start_line)) - 1;
889
+ const end = args.end_line ? Math.min(Math.floor(args.end_line), allLines.length) : allLines.length;
890
+ const selected = allLines.slice(start, end);
891
+ return mcpResult(JSON.stringify({ path: args.path, start: start + 1, end, total_lines: allLines.length, content: selected.join("\n") }));
892
+ } catch (err) {
893
+ return mcpError(`Read failed: ${err.message}`);
894
+ }
895
+ }
896
+
897
+ case "fs_grep_json": {
898
+ const searchPath = args.path || ".";
899
+ const r = await resolve(searchPath, args.mount);
900
+ if (r.error) return mcpError(r.error);
901
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
902
+ const maxResults = Math.min(Number(args.max_results || 50), 200);
903
+ const ctxLines = Math.min(Number(args.context_lines || 0), 10);
904
+ try {
905
+ const rgArgs = ["--json", "-e", args.pattern, "--max-count", String(maxResults)];
906
+ if (ctxLines > 0) rgArgs.push("-C", String(ctxLines));
907
+ if (args.glob) rgArgs.push("-g", args.glob);
908
+ rgArgs.push(r.resolved);
909
+ const raw = execSyncTop(`"${rgPath}" ${rgArgs.map(a => `"${a}"`).join(" ")}`, { encoding: "utf8", timeout: 30000, windowsHide: true, maxBuffer: 1024 * 1024 });
910
+ const matches = [];
911
+ for (const line of raw.split("\n").filter(Boolean)) {
912
+ try {
913
+ const obj = JSON.parse(line);
914
+ if (obj.type === "match") {
915
+ const rel = path.relative(r.resolved, obj.data.path.text).replace(/\\/g, "/");
916
+ matches.push({ file: rel, line: obj.data.line_number, match: obj.data.lines.text.trimEnd() });
917
+ }
918
+ } catch {}
919
+ }
920
+ return mcpResult(JSON.stringify({ matches, total_matches: matches.length, truncated: matches.length >= maxResults }));
921
+ } catch (err) {
922
+ if (err.status === 1) return mcpResult(JSON.stringify({ matches: [], total_matches: 0, truncated: false }));
923
+ return mcpError(`grep failed: ${err.message}`);
924
+ }
925
+ }
926
+
927
+ case "fs_search": {
928
+ const searchBase = args.path || ".";
929
+ const r = await resolve(searchBase, args.mount);
930
+ if (r.error) return mcpError(r.error);
931
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
932
+ const maxResults = Math.min(Number(args.max_results || 50), 200);
933
+ try {
934
+ const globPattern = args.query || (args.file_types ? `**/*{${args.file_types.join(",")}}` : "**/*");
935
+ const files = await fg(globPattern, { cwd: r.resolved, absolute: true, stats: true, dot: false, ignore: ["**/node_modules/**", "**/.next/**", "**/.git/**"] });
936
+ let results = [];
937
+ for (const f of files) {
938
+ const st = f.stats || {};
939
+ const mtime = st.mtime ? new Date(st.mtime) : null;
940
+ if (args.modified_after && mtime && mtime < new Date(args.modified_after)) continue;
941
+ if (args.modified_before && mtime && mtime > new Date(args.modified_before)) continue;
942
+ const rel = path.relative(r.resolved, f.path).replace(/\\/g, "/");
943
+ const entry = { path: rel, size: st.size || 0, modified: mtime?.toISOString() || null };
944
+ if (args.content_pattern) {
945
+ try {
946
+ const text = await readFile(f.path, "utf8");
947
+ const match = text.match(new RegExp(args.content_pattern));
948
+ if (!match) continue;
949
+ const lineIdx = text.substring(0, match.index).split("\n").length;
950
+ entry.match_line = lineIdx;
951
+ entry.match_text = match[0].slice(0, 200);
952
+ } catch { continue; }
953
+ }
954
+ results.push(entry);
955
+ if (results.length >= maxResults) break;
956
+ }
957
+ return mcpResult(JSON.stringify({ results, total: results.length }));
958
+ } catch (err) {
959
+ return mcpError(`search failed: ${err.message}`);
960
+ }
961
+ }
962
+
963
+ case "fs_put": {
964
+ const r = await resolve(args.path, args.mount);
965
+ if (r.error) return mcpError(r.error);
966
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied");
967
+ try {
968
+ const exists = await stat(r.resolved).then(() => true).catch(() => false);
969
+ if (exists && !args.overwrite) return mcpError(`File exists: ${args.path} (set overwrite=true)`);
970
+ const buf = Buffer.from(args.content_base64, "base64");
971
+ const dir = path.dirname(r.resolved);
972
+ await mkdir(dir, { recursive: true });
973
+ await atomicWriteText(r.resolved, buf);
974
+ const hash = crypto.createHash("sha256").update(buf).digest("hex");
975
+ return mcpResult(JSON.stringify({ path: args.path, bytes: buf.length, sha256: hash }));
976
+ } catch (err) {
977
+ return mcpError(`Put failed: ${err.message}`);
978
+ }
979
+ }
980
+
981
+ case "fs_dav_setup": {
982
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
983
+ const ttl = Math.min(Number(args.ttl_seconds || 14400), 86400);
984
+
985
+ const startResult = await webdavService.ensureRunning(() => vault);
986
+ if (startResult.error) return mcpError(`WebDAV setup failed: ${startResult.error}`);
987
+
988
+ const wdStatus = webdavService.getStatus();
989
+ if (wdStatus.status === "error") {
990
+ return mcpError(
991
+ `WebDAV child error: ${wdStatus.error}\n` +
992
+ `Restarts so far: ${wdStatus.restart_count}. Retry or check clauth logs.`
993
+ );
994
+ }
995
+
996
+ let plainPass = "";
997
+ try {
998
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
999
+ const cr = await api.retrieve(vault.password, vault.machineHash, token, timestamp, "webdav-claude");
1000
+ if (cr.value) plainPass = cr.value;
1001
+ } catch {}
1002
+
1003
+ const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
1004
+
1005
+ const setupScript = [
1006
+ "#!/usr/bin/env bash",
1007
+ "# WebDAV setup — generated by fs_dav_setup (clauth)",
1008
+ `# Expires hint: ${expiresAt}`,
1009
+ "set -e",
1010
+ "",
1011
+ "# Configure rclone WebDAV remote via env vars (no config file, no mount, no daemon)",
1012
+ "export RCLONE_CONFIG_DAV_TYPE=webdav",
1013
+ "export RCLONE_CONFIG_DAV_URL=https://dav.regendevcorp.com",
1014
+ "export RCLONE_CONFIG_DAV_VENDOR=rclone",
1015
+ "export RCLONE_CONFIG_DAV_USER=claude",
1016
+ plainPass
1017
+ ? `export RCLONE_CONFIG_DAV_PASS=$(rclone obscure "${plainPass}")`
1018
+ : "# RCLONE_CONFIG_DAV_PASS — no credential found in vault",
1019
+ "",
1020
+ "# Verify connectivity",
1021
+ "rclone lsd dav: --quiet",
1022
+ 'echo "DAV ready — use rclone commands directly:"',
1023
+ 'echo " rclone lsd dav:corpus/ # list dirs"',
1024
+ 'echo " rclone cat dav:corpus/file.md # read file"',
1025
+ 'echo " rclone rcat dav:dev/file.txt <<< content # write file"',
1026
+ 'echo " rclone copy local/ dav:dev/path/ # upload"',
1027
+ 'echo " rclone cat dav:file | grep pattern # search"',
1028
+ ].join("\n");
1029
+
1030
+ return mcpResult(JSON.stringify({
1031
+ status: wdStatus.status,
1032
+ url: "https://dav.regendevcorp.com",
1033
+ setup_script: setupScript,
1034
+ expires_at: expiresAt,
1035
+ ttl_seconds: ttl,
1036
+ webdav_user: "claude",
1037
+ started_at: wdStatus.started_at,
1038
+ mounts: (webdavService.loadConfig().upstreams || []).map(u => ({
1039
+ rclone_path: `dav:${u.name}/`,
1040
+ host_path: u.path,
1041
+ description: u.name === "corpus" ? "Global corpus ($CORPUS_ROOT on host — Google Drive)" : `Local filesystem (${u.path} on host)`,
1042
+ })),
1043
+ deprecated_tools: ["fs_read", "fs_list", "fs_stat", "fs_edit", "fs_mkdir", "fs_use_branch"],
1044
+ deprecated_note: "Use WebDAV (rclone cat/ls/lsd) instead — 37-56x faster. These tools remain as fallback.",
1045
+ exec_enabled: true,
1046
+ exec_allowlist: ["git", "pnpm", "npx", "node", "python3", "rclone", "bash", "cat", "grep", "find", "wc", "sha256sum", "tsc", "eslint"],
1047
+ new_tools: ["fs_exec", "fs_hash", "fs_cat_lines", "fs_grep_json", "fs_search", "fs_put"],
1048
+ 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.",
1049
+ }, null, 2));
1050
+ }
1051
+
1052
+ default:
1053
+ return null;
1054
+ }
1055
+ }