@lifeaitools/clauth 1.30.3 → 1.30.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/agent-pool.js +22 -6
- package/cli/commands/serve.js +1070 -144
- package/cli/enrollment-script.js +62 -0
- package/cli/index.js +4 -43
- package/package.json +1 -1
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/cli/commands/serve/tools/fs.js +0 -1055
package/cli/commands/serve.js
CHANGED
|
@@ -14,7 +14,7 @@ import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
|
|
|
14
14
|
import * as api from "../api.js";
|
|
15
15
|
import chalk from "chalk";
|
|
16
16
|
import ora from "ora";
|
|
17
|
-
import { execSync as execSyncTop } from "child_process";
|
|
17
|
+
import { execFileSync, execSync as execSyncTop } from "child_process";
|
|
18
18
|
import Conf from "conf";
|
|
19
19
|
import { getConfOptions } from "../conf-path.js";
|
|
20
20
|
import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
|
|
@@ -22,9 +22,9 @@ import fg from "fast-glob";
|
|
|
22
22
|
import { rgPath } from "@vscode/ripgrep";
|
|
23
23
|
import { createStudioDebugRuntime } from "../studio-debug.js";
|
|
24
24
|
import { writeCredentialWithRecovery } from "../recovery.js";
|
|
25
|
+
import { writeEnrollmentScript } from "../enrollment-script.js";
|
|
25
26
|
import * as fsGit from "../lib/fs-git.js";
|
|
26
27
|
import * as webdavService from "../webdav-service.js";
|
|
27
|
-
import { handleFsTool } from "./serve/tools/fs.js";
|
|
28
28
|
import {
|
|
29
29
|
getWatchdogStatuses,
|
|
30
30
|
readWatchdogEvents,
|
|
@@ -137,98 +137,6 @@ END $$;`,
|
|
|
137
137
|
|
|
138
138
|
const CURRENT_SCHEMA_VERSION = 6;
|
|
139
139
|
|
|
140
|
-
function shellSingleQuote(value) {
|
|
141
|
-
return `'${String(value ?? "").replace(/'/g, "''")}'`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function enrollmentScriptName(label) {
|
|
145
|
-
const slug = String(label || "new-computer")
|
|
146
|
-
.toLowerCase()
|
|
147
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
148
|
-
.replace(/^-+|-+$/g, "")
|
|
149
|
-
.slice(0, 40) || "new-computer";
|
|
150
|
-
return `clauth-enroll-${slug}.ps1`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode, label }) {
|
|
154
|
-
const appDir = os.platform() === "win32"
|
|
155
|
-
? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "clauth")
|
|
156
|
-
: path.join(os.homedir(), ".config", "clauth");
|
|
157
|
-
fs.mkdirSync(appDir, { recursive: true });
|
|
158
|
-
const scriptPath = path.join(appDir, enrollmentScriptName(label));
|
|
159
|
-
const script = [
|
|
160
|
-
"$ErrorActionPreference = 'Stop'",
|
|
161
|
-
"",
|
|
162
|
-
"# Check prerequisites",
|
|
163
|
-
"if (-not (Get-Command node -ErrorAction SilentlyContinue)) {",
|
|
164
|
-
" Write-Host '\\nERROR: Node.js is not installed.' -ForegroundColor Red",
|
|
165
|
-
" Write-Host 'Install Node.js 22+ from https://nodejs.org then re-run this script.' -ForegroundColor Yellow",
|
|
166
|
-
" Read-Host 'Press Enter to exit'",
|
|
167
|
-
" exit 1",
|
|
168
|
-
"}",
|
|
169
|
-
"if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {",
|
|
170
|
-
" Write-Host '\\nERROR: npm is not on PATH.' -ForegroundColor Red",
|
|
171
|
-
" Write-Host 'Reinstall Node.js from https://nodejs.org (ensure npm is included).' -ForegroundColor Yellow",
|
|
172
|
-
" Read-Host 'Press Enter to exit'",
|
|
173
|
-
" exit 1",
|
|
174
|
-
"}",
|
|
175
|
-
"",
|
|
176
|
-
"$label = $env:COMPUTERNAME",
|
|
177
|
-
"if (-not $label) { $label = [System.Net.Dns]::GetHostName() }",
|
|
178
|
-
"",
|
|
179
|
-
"Write-Host '\\n=== clauth enrollment ===' -ForegroundColor Cyan",
|
|
180
|
-
"Write-Host \"Machine: $label\"",
|
|
181
|
-
"",
|
|
182
|
-
"Write-Host '\\n[1/3] Installing clauth...' -ForegroundColor Cyan",
|
|
183
|
-
"try {",
|
|
184
|
-
" npm install -g @lifeaitools/clauth@latest",
|
|
185
|
-
" if ($LASTEXITCODE -ne 0) { throw 'npm install failed' }",
|
|
186
|
-
"} catch {",
|
|
187
|
-
" Write-Host \"\\nERROR: Failed to install clauth: $_\" -ForegroundColor Red",
|
|
188
|
-
" Write-Host 'Check your network connection and npm access.' -ForegroundColor Yellow",
|
|
189
|
-
" Read-Host 'Press Enter to exit'",
|
|
190
|
-
" exit 1",
|
|
191
|
-
"}",
|
|
192
|
-
"",
|
|
193
|
-
"Write-Host '\\n[2/3] Enrolling this computer...' -ForegroundColor Cyan",
|
|
194
|
-
"Write-Host 'You will be asked to set a vault password for this machine.' -ForegroundColor Yellow",
|
|
195
|
-
"try {",
|
|
196
|
-
" " + [
|
|
197
|
-
"clauth setup",
|
|
198
|
-
`--supabase-url ${shellSingleQuote(supabaseUrl)}`,
|
|
199
|
-
`--anon-key ${shellSingleQuote(anonKey)}`,
|
|
200
|
-
`--enrollment-code ${shellSingleQuote(enrollmentCode)}`,
|
|
201
|
-
"--label \"$label\"",
|
|
202
|
-
].join(" "),
|
|
203
|
-
" if ($LASTEXITCODE -ne 0) { throw 'clauth setup failed' }",
|
|
204
|
-
"} catch {",
|
|
205
|
-
" Write-Host \"\\nERROR: Enrollment failed: $_\" -ForegroundColor Red",
|
|
206
|
-
" Write-Host 'The enrollment code may have expired. Generate a new one from the dashboard.' -ForegroundColor Yellow",
|
|
207
|
-
" Read-Host 'Press Enter to exit'",
|
|
208
|
-
" exit 1",
|
|
209
|
-
"}",
|
|
210
|
-
"",
|
|
211
|
-
"Write-Host '\\n[3/3] Installing auto-start service...' -ForegroundColor Cyan",
|
|
212
|
-
"try {",
|
|
213
|
-
" clauth serve install",
|
|
214
|
-
"} catch {",
|
|
215
|
-
" Write-Host \"WARNING: Auto-start setup failed: $_\" -ForegroundColor Yellow",
|
|
216
|
-
" Write-Host 'clauth is enrolled but will need to be started manually.' -ForegroundColor Yellow",
|
|
217
|
-
"}",
|
|
218
|
-
"",
|
|
219
|
-
"Write-Host '\\n=== clauth enrollment complete ===' -ForegroundColor Green",
|
|
220
|
-
"Write-Host 'Run: clauth test to verify the connection'",
|
|
221
|
-
"Write-Host 'Run: clauth status to see all services'",
|
|
222
|
-
"Read-Host '\\nPress Enter to exit'",
|
|
223
|
-
"",
|
|
224
|
-
"# Self-delete",
|
|
225
|
-
"$self = $PSCommandPath",
|
|
226
|
-
"Start-Process powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile','-Command',\"Start-Sleep -Seconds 2; Remove-Item -LiteralPath '$self' -Force -ErrorAction SilentlyContinue\")",
|
|
227
|
-
].join("\r\n");
|
|
228
|
-
fs.writeFileSync(scriptPath, `${script}\r\n`, "utf8");
|
|
229
|
-
return scriptPath;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
140
|
// ── Key Rotation Config ─────────────────────────────────────────
|
|
233
141
|
// Per-service rotation capabilities. "auto" services can be rotated programmatically.
|
|
234
142
|
// "manual" services require dashboard/UI action. "none" means static keys.
|
|
@@ -978,7 +886,8 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
978
886
|
</div>
|
|
979
887
|
</div>
|
|
980
888
|
<div style="display:flex;gap:8px;margin-top:8px">
|
|
981
|
-
<button class="btn-chpw-save" onclick="enrollMachine()">
|
|
889
|
+
<button class="btn-chpw-save" onclick="enrollMachine('windows')">Windows Script</button>
|
|
890
|
+
<button class="btn-chpw-save" onclick="enrollMachine('linux')">Linux Script</button>
|
|
982
891
|
<button class="btn-cancel" onclick="toggleEnrollPanel()">Cancel</button>
|
|
983
892
|
</div>
|
|
984
893
|
<div id="enroll-msg" class="add-msg"></div>
|
|
@@ -2440,7 +2349,7 @@ function toggleEnrollPanel() {
|
|
|
2440
2349
|
document.getElementById("enroll-result").style.display = "none";
|
|
2441
2350
|
}
|
|
2442
2351
|
|
|
2443
|
-
async function enrollMachine() {
|
|
2352
|
+
async function enrollMachine(target) {
|
|
2444
2353
|
const label = document.getElementById("enroll-label").value.trim() || "new-computer";
|
|
2445
2354
|
const ttl = (Number(document.getElementById("enroll-ttl").value) || 1) * 60;
|
|
2446
2355
|
const msg = document.getElementById("enroll-msg");
|
|
@@ -2451,7 +2360,7 @@ async function enrollMachine() {
|
|
|
2451
2360
|
const r = await fetch(BASE + "/enroll-machine", {
|
|
2452
2361
|
method: "POST",
|
|
2453
2362
|
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
2454
|
-
body: JSON.stringify({ label, ttl_minutes: Number(ttl) })
|
|
2363
|
+
body: JSON.stringify({ label, ttl_minutes: Number(ttl), target })
|
|
2455
2364
|
}).then(r => r.json());
|
|
2456
2365
|
if (r.locked) { showLockScreen(); return; }
|
|
2457
2366
|
if (r.error) throw new Error(r.error);
|
|
@@ -7490,6 +7399,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7490
7399
|
|
|
7491
7400
|
const label = (body.label || "new-computer").trim();
|
|
7492
7401
|
const ttlMinutes = Number(body.ttl_minutes) || 60;
|
|
7402
|
+
const target = body.target === "linux" ? "linux" : "windows";
|
|
7493
7403
|
|
|
7494
7404
|
try {
|
|
7495
7405
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
@@ -7498,7 +7408,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7498
7408
|
const localConfig = new Conf(getConfOptions());
|
|
7499
7409
|
const supabaseUrl = localConfig.get("supabase_url") || process.env.CLAUTH_SUPABASE_URL || "";
|
|
7500
7410
|
const anonKey = localConfig.get("supabase_anon_key") || process.env.CLAUTH_SUPABASE_ANON_KEY || "";
|
|
7501
|
-
const scriptPath = writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode: result.enrollment_code, label });
|
|
7411
|
+
const scriptPath = writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode: result.enrollment_code, label, target });
|
|
7502
7412
|
return ok(res, {
|
|
7503
7413
|
ok: true,
|
|
7504
7414
|
enrollment_code: result.enrollment_code,
|
|
@@ -7566,7 +7476,8 @@ async function actionStart(opts) {
|
|
|
7566
7476
|
|
|
7567
7477
|
const isStaged = !!opts.staged || process.env.__CLAUTH_STAGED === "1";
|
|
7568
7478
|
const port = isStaged ? STAGED_PORT : parseInt(opts.port || String(LIVE_PORT), 10);
|
|
7569
|
-
let password = opts.pw || null;
|
|
7479
|
+
let password = opts.pw || (opts.pwEnv ? process.env.CLAUTH_BOOT_PASSWORD : null);
|
|
7480
|
+
if (opts.pwEnv) delete process.env.CLAUTH_BOOT_PASSWORD;
|
|
7570
7481
|
let fromBootKey = !!opts.fromBootKey;
|
|
7571
7482
|
|
|
7572
7483
|
// Auto-unlock: if no --pw flag, try to decrypt boot.key (DPAPI on Windows, openssl on Linux)
|
|
@@ -7718,10 +7629,12 @@ async function actionStart(opts) {
|
|
|
7718
7629
|
const __filename = fileURLToPath(import.meta.url);
|
|
7719
7630
|
const cliEntry = join(dirname(__filename), "..", "index.js");
|
|
7720
7631
|
|
|
7721
|
-
// Build args:
|
|
7632
|
+
// Build args without the password: long-lived service wrappers keep it out of ps output.
|
|
7722
7633
|
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
7634
|
+
const childEnv = { ...process.env, __CLAUTH_DAEMON: "1", ...(isStaged ? { __CLAUTH_STAGED: "1" } : {}) };
|
|
7723
7635
|
if (password) {
|
|
7724
|
-
childArgs.push("--pw"
|
|
7636
|
+
childArgs.push("--pw-env");
|
|
7637
|
+
childEnv.CLAUTH_BOOT_PASSWORD = password;
|
|
7725
7638
|
if (fromBootKey) childArgs.push("--from-boot-key");
|
|
7726
7639
|
}
|
|
7727
7640
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
@@ -7732,7 +7645,7 @@ async function actionStart(opts) {
|
|
|
7732
7645
|
const child = spawn(process.execPath, childArgs, {
|
|
7733
7646
|
detached: true,
|
|
7734
7647
|
stdio: ["ignore", out, out],
|
|
7735
|
-
env:
|
|
7648
|
+
env: childEnv,
|
|
7736
7649
|
windowsHide: true,
|
|
7737
7650
|
});
|
|
7738
7651
|
child.unref();
|
|
@@ -9816,6 +9729,160 @@ async function searchServices({ password, machineHash, token, timestamp, query,
|
|
|
9816
9729
|
return { query, count: matches.length, matches };
|
|
9817
9730
|
}
|
|
9818
9731
|
|
|
9732
|
+
// ── Filesystem service config — loaded from clauth vault ──
|
|
9733
|
+
let _fsMountsCache = null;
|
|
9734
|
+
let _fsMountsCacheTime = 0;
|
|
9735
|
+
const FS_CACHE_TTL = 60000; // 1 minute
|
|
9736
|
+
|
|
9737
|
+
async function getFileserverMounts(vault) {
|
|
9738
|
+
if (!vault.password) return { error: "Vault is locked — unlock first" };
|
|
9739
|
+
const now = Date.now();
|
|
9740
|
+
if (_fsMountsCache && now - _fsMountsCacheTime < FS_CACHE_TTL) return { mounts: _fsMountsCache };
|
|
9741
|
+
|
|
9742
|
+
try {
|
|
9743
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
9744
|
+
const result = await api.status(vault.password, vault.machineHash, token, timestamp);
|
|
9745
|
+
if (result.error) return { error: result.error };
|
|
9746
|
+
const mounts = [];
|
|
9747
|
+
for (const s of (result.services || [])) {
|
|
9748
|
+
if (s.key_type === "fileserver" && s.enabled) {
|
|
9749
|
+
try {
|
|
9750
|
+
const { token: t2, timestamp: ts2 } = deriveToken(vault.password, vault.machineHash);
|
|
9751
|
+
const secret = await api.retrieve(vault.password, vault.machineHash, t2, ts2, s.name);
|
|
9752
|
+
if (secret.value) {
|
|
9753
|
+
const config = JSON.parse(secret.value);
|
|
9754
|
+
mounts.push({ name: s.name, path: config.path, access: config.access || "r" });
|
|
9755
|
+
}
|
|
9756
|
+
} catch {}
|
|
9757
|
+
}
|
|
9758
|
+
}
|
|
9759
|
+
_fsMountsCache = mounts;
|
|
9760
|
+
_fsMountsCacheTime = now;
|
|
9761
|
+
return { mounts };
|
|
9762
|
+
} catch (err) {
|
|
9763
|
+
return { error: `Mount lookup failed: ${err.message}` };
|
|
9764
|
+
}
|
|
9765
|
+
}
|
|
9766
|
+
|
|
9767
|
+
async function resolveInMount(requestedPath, mountName, vault) {
|
|
9768
|
+
const { mounts, error } = await getFileserverMounts(vault);
|
|
9769
|
+
if (error) return { error };
|
|
9770
|
+
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)" };
|
|
9771
|
+
const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
|
|
9772
|
+
if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
|
|
9773
|
+
if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
|
|
9774
|
+
const resolved = path.resolve(mount.path, requestedPath);
|
|
9775
|
+
const normalized = path.normalize(resolved);
|
|
9776
|
+
const relative = path.relative(path.normalize(mount.path), normalized);
|
|
9777
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
9778
|
+
return { error: `Path escapes mount: ${requestedPath}` };
|
|
9779
|
+
}
|
|
9780
|
+
return { resolved: normalized, mount };
|
|
9781
|
+
}
|
|
9782
|
+
|
|
9783
|
+
function checkAccess(mount, flag) {
|
|
9784
|
+
return mount.access.includes(flag);
|
|
9785
|
+
}
|
|
9786
|
+
|
|
9787
|
+
function sha256Hex(value) {
|
|
9788
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
9789
|
+
}
|
|
9790
|
+
|
|
9791
|
+
async function atomicWriteText(filePath, content) {
|
|
9792
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
9793
|
+
const tempPath = path.join(
|
|
9794
|
+
path.dirname(filePath),
|
|
9795
|
+
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
9796
|
+
);
|
|
9797
|
+
await writeFile(tempPath, content, "utf8");
|
|
9798
|
+
await rename(tempPath, filePath);
|
|
9799
|
+
}
|
|
9800
|
+
|
|
9801
|
+
async function fileInfo(filePath, requestedPath) {
|
|
9802
|
+
const s = await stat(filePath);
|
|
9803
|
+
const info = {
|
|
9804
|
+
path: requestedPath,
|
|
9805
|
+
type: s.isDirectory() ? "dir" : "file",
|
|
9806
|
+
size: s.size,
|
|
9807
|
+
modified: s.mtime.toISOString(),
|
|
9808
|
+
};
|
|
9809
|
+
if (s.isFile()) {
|
|
9810
|
+
const content = await readFile(filePath);
|
|
9811
|
+
info.sha256 = sha256Hex(content);
|
|
9812
|
+
}
|
|
9813
|
+
return info;
|
|
9814
|
+
}
|
|
9815
|
+
|
|
9816
|
+
const FS_UPLOAD_SESSIONS = new Map();
|
|
9817
|
+
const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
|
9818
|
+
const FS_MAX_CHUNKS = 500;
|
|
9819
|
+
const FS_MAX_CHUNK_BYTES = 128 * 1024;
|
|
9820
|
+
const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
|
|
9821
|
+
const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
|
|
9822
|
+
"docs/",
|
|
9823
|
+
".rdc/plans/",
|
|
9824
|
+
".rdc/guides/",
|
|
9825
|
+
".claude/context/",
|
|
9826
|
+
".claude/rules/",
|
|
9827
|
+
".rdc/relay/from-claude-ai/",
|
|
9828
|
+
];
|
|
9829
|
+
|
|
9830
|
+
function cleanupFsUploadSessions() {
|
|
9831
|
+
const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
|
|
9832
|
+
for (const [id, session] of FS_UPLOAD_SESSIONS) {
|
|
9833
|
+
if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
|
|
9834
|
+
}
|
|
9835
|
+
}
|
|
9836
|
+
|
|
9837
|
+
function runGit(cwd, args, opts = {}) {
|
|
9838
|
+
const res = spawnSync("git", args, {
|
|
9839
|
+
cwd,
|
|
9840
|
+
encoding: "utf8",
|
|
9841
|
+
windowsHide: true,
|
|
9842
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
9843
|
+
});
|
|
9844
|
+
if (res.status !== 0) {
|
|
9845
|
+
const detail = (res.stderr || res.stdout || "").trim();
|
|
9846
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
9847
|
+
}
|
|
9848
|
+
return (res.stdout || "").trim();
|
|
9849
|
+
}
|
|
9850
|
+
|
|
9851
|
+
function runGitRaw(cwd, args, opts = {}) {
|
|
9852
|
+
const res = spawnSync("git", args, {
|
|
9853
|
+
cwd,
|
|
9854
|
+
encoding: "buffer",
|
|
9855
|
+
windowsHide: true,
|
|
9856
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
9857
|
+
});
|
|
9858
|
+
if (res.status !== 0) {
|
|
9859
|
+
const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
|
|
9860
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
9861
|
+
}
|
|
9862
|
+
return res.stdout || Buffer.alloc(0);
|
|
9863
|
+
}
|
|
9864
|
+
|
|
9865
|
+
// Read a single credential value from the vault inside an MCP handler.
|
|
9866
|
+
// (The git operations themselves live in ../lib/fs-git.js — pure + testable.)
|
|
9867
|
+
async function vaultRetrieveValue(vault, service) {
|
|
9868
|
+
if (!vault.password) return { error: "locked" };
|
|
9869
|
+
if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
|
|
9870
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
9871
|
+
return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
|
|
9872
|
+
}
|
|
9873
|
+
|
|
9874
|
+
function normalizeRepoPath(p) {
|
|
9875
|
+
if (!p || typeof p !== "string") return null;
|
|
9876
|
+
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
9877
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
9878
|
+
if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
|
|
9879
|
+
return parts.join("/");
|
|
9880
|
+
}
|
|
9881
|
+
|
|
9882
|
+
function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
|
|
9883
|
+
return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
|
|
9884
|
+
}
|
|
9885
|
+
|
|
9819
9886
|
const MCP_TOOLS = [
|
|
9820
9887
|
{
|
|
9821
9888
|
name: "clauth_ping",
|
|
@@ -10853,14 +10920,6 @@ async function handleMcpTool(vault, name, args) {
|
|
|
10853
10920
|
return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
|
|
10854
10921
|
};
|
|
10855
10922
|
|
|
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
|
-
|
|
10864
10923
|
switch (name) {
|
|
10865
10924
|
case "clauth_ping": {
|
|
10866
10925
|
return mcpResult(
|
|
@@ -11323,6 +11382,603 @@ async function handleMcpTool(vault, name, args) {
|
|
|
11323
11382
|
|
|
11324
11383
|
// ── Filesystem tools ──────────────────────────────────────
|
|
11325
11384
|
|
|
11385
|
+
case "fs_read": {
|
|
11386
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11387
|
+
if (r.error) return mcpError(r.error);
|
|
11388
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
11389
|
+
try {
|
|
11390
|
+
const content = await readFile(r.resolved, "utf8");
|
|
11391
|
+
const lines = content.split("\n");
|
|
11392
|
+
const offset = args.offset || 0;
|
|
11393
|
+
const limit = args.limit || 500;
|
|
11394
|
+
const slice = lines.slice(offset, offset + limit);
|
|
11395
|
+
const numbered = slice.map((line, i) => `${offset + i + 1}\t${line}`).join("\n");
|
|
11396
|
+
const header = `${r.resolved} (${lines.length} lines${offset > 0 ? `, showing ${offset + 1}-${Math.min(offset + limit, lines.length)}` : ""})`;
|
|
11397
|
+
return mcpResult(`${header}\n${numbered}`);
|
|
11398
|
+
} catch (err) {
|
|
11399
|
+
if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
|
|
11400
|
+
return mcpError(`Read failed: ${err.message}`);
|
|
11401
|
+
}
|
|
11402
|
+
}
|
|
11403
|
+
|
|
11404
|
+
case "fs_write": {
|
|
11405
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11406
|
+
if (r.error) return mcpError(r.error);
|
|
11407
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11408
|
+
try {
|
|
11409
|
+
await atomicWriteText(r.resolved, args.content);
|
|
11410
|
+
return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
|
|
11411
|
+
} catch (err) {
|
|
11412
|
+
return mcpError(`Write failed: ${err.message}`);
|
|
11413
|
+
}
|
|
11414
|
+
}
|
|
11415
|
+
|
|
11416
|
+
case "fs_stat": {
|
|
11417
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11418
|
+
if (r.error) return mcpError(r.error);
|
|
11419
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
11420
|
+
try {
|
|
11421
|
+
return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
|
|
11422
|
+
} catch (err) {
|
|
11423
|
+
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
11424
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
11425
|
+
}
|
|
11426
|
+
}
|
|
11427
|
+
|
|
11428
|
+
case "fs_append": {
|
|
11429
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11430
|
+
if (r.error) return mcpError(r.error);
|
|
11431
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11432
|
+
try {
|
|
11433
|
+
try {
|
|
11434
|
+
const current = await readFile(r.resolved);
|
|
11435
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
11436
|
+
return mcpError("Append rejected: current file hash does not match expected_sha256");
|
|
11437
|
+
}
|
|
11438
|
+
} catch (err) {
|
|
11439
|
+
if (err.code !== "ENOENT") throw err;
|
|
11440
|
+
if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
|
|
11441
|
+
}
|
|
11442
|
+
await mkdir(path.dirname(r.resolved), { recursive: true });
|
|
11443
|
+
await appendFile(r.resolved, args.content, "utf8");
|
|
11444
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
11445
|
+
return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
|
|
11446
|
+
} catch (err) {
|
|
11447
|
+
return mcpError(`Append failed: ${err.message}`);
|
|
11448
|
+
}
|
|
11449
|
+
}
|
|
11450
|
+
|
|
11451
|
+
case "fs_write_chunk": {
|
|
11452
|
+
cleanupFsUploadSessions();
|
|
11453
|
+
const { upload_id, chunk_index, total_chunks, content } = args;
|
|
11454
|
+
const index = Number(chunk_index);
|
|
11455
|
+
const total = Number(total_chunks);
|
|
11456
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
11457
|
+
return mcpError("Invalid chunk_index/total_chunks");
|
|
11458
|
+
}
|
|
11459
|
+
if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
|
|
11460
|
+
if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
|
|
11461
|
+
return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
|
|
11462
|
+
}
|
|
11463
|
+
|
|
11464
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11465
|
+
if (r.error) return mcpError(r.error);
|
|
11466
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11467
|
+
|
|
11468
|
+
const key = `${r.mount.name}:${args.path}:${upload_id}`;
|
|
11469
|
+
let session = FS_UPLOAD_SESSIONS.get(key);
|
|
11470
|
+
if (!session) {
|
|
11471
|
+
session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
|
|
11472
|
+
FS_UPLOAD_SESSIONS.set(key, session);
|
|
11473
|
+
}
|
|
11474
|
+
if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
|
|
11475
|
+
return mcpError("Upload id collision: path or total_chunks differs from existing session");
|
|
11476
|
+
}
|
|
11477
|
+
if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
|
|
11478
|
+
return mcpError("Upload id collision: expected_sha256 differs from existing session");
|
|
11479
|
+
}
|
|
11480
|
+
|
|
11481
|
+
session.chunks.set(index, content);
|
|
11482
|
+
session.updatedAt = Date.now();
|
|
11483
|
+
|
|
11484
|
+
if (session.chunks.size < total) {
|
|
11485
|
+
return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
|
|
11486
|
+
}
|
|
11487
|
+
|
|
11488
|
+
const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
|
|
11489
|
+
const actualSha = sha256Hex(assembled);
|
|
11490
|
+
if (session.expectedSha256 && actualSha !== session.expectedSha256) {
|
|
11491
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
11492
|
+
return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
|
|
11493
|
+
}
|
|
11494
|
+
|
|
11495
|
+
try {
|
|
11496
|
+
await atomicWriteText(r.resolved, assembled);
|
|
11497
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
11498
|
+
return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
|
|
11499
|
+
} catch (err) {
|
|
11500
|
+
return mcpError(`Chunked write failed: ${err.message}`);
|
|
11501
|
+
}
|
|
11502
|
+
}
|
|
11503
|
+
|
|
11504
|
+
case "fs_ingest_url": {
|
|
11505
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11506
|
+
if (r.error) return mcpError(r.error);
|
|
11507
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11508
|
+
|
|
11509
|
+
let url;
|
|
11510
|
+
try {
|
|
11511
|
+
url = new URL(args.url);
|
|
11512
|
+
} catch {
|
|
11513
|
+
return mcpError("Invalid URL");
|
|
11514
|
+
}
|
|
11515
|
+
if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
|
|
11516
|
+
|
|
11517
|
+
const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
|
|
11518
|
+
try {
|
|
11519
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
11520
|
+
if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
|
|
11521
|
+
const length = Number(response.headers.get("content-length") || 0);
|
|
11522
|
+
if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
|
|
11523
|
+
|
|
11524
|
+
const reader = response.body?.getReader();
|
|
11525
|
+
if (!reader) return mcpError("Fetch failed: response body is not readable");
|
|
11526
|
+
|
|
11527
|
+
let received = 0;
|
|
11528
|
+
const chunks = [];
|
|
11529
|
+
while (true) {
|
|
11530
|
+
const { done, value } = await reader.read();
|
|
11531
|
+
if (done) break;
|
|
11532
|
+
received += value.byteLength;
|
|
11533
|
+
if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
|
|
11534
|
+
chunks.push(Buffer.from(value));
|
|
11535
|
+
}
|
|
11536
|
+
|
|
11537
|
+
const content = Buffer.concat(chunks).toString("utf8");
|
|
11538
|
+
const actualSha = sha256Hex(content);
|
|
11539
|
+
if (args.expected_sha256 && actualSha !== args.expected_sha256) {
|
|
11540
|
+
return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
|
|
11541
|
+
}
|
|
11542
|
+
await atomicWriteText(r.resolved, content);
|
|
11543
|
+
return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
|
|
11544
|
+
} catch (err) {
|
|
11545
|
+
return mcpError(`Ingest failed: ${err.message}`);
|
|
11546
|
+
}
|
|
11547
|
+
}
|
|
11548
|
+
|
|
11549
|
+
case "fs_import_git_files": {
|
|
11550
|
+
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
11551
|
+
if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
|
|
11552
|
+
|
|
11553
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
11554
|
+
if (r.error) return mcpError(r.error);
|
|
11555
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11556
|
+
|
|
11557
|
+
const repoRoot = r.resolved;
|
|
11558
|
+
const remote = args.remote || "origin";
|
|
11559
|
+
const mode = args.mode || "new_only";
|
|
11560
|
+
const doCommit = args.commit === true;
|
|
11561
|
+
const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
|
|
11562
|
+
? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
|
|
11563
|
+
: FS_GIT_IMPORT_ALLOWED_PREFIXES;
|
|
11564
|
+
|
|
11565
|
+
try {
|
|
11566
|
+
const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
11567
|
+
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
11568
|
+
return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
|
|
11569
|
+
}
|
|
11570
|
+
|
|
11571
|
+
const normalizedPaths = [];
|
|
11572
|
+
for (const rawPath of args.paths) {
|
|
11573
|
+
const normalized = normalizeRepoPath(rawPath);
|
|
11574
|
+
if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
|
|
11575
|
+
if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
|
|
11576
|
+
normalizedPaths.push(normalized);
|
|
11577
|
+
}
|
|
11578
|
+
|
|
11579
|
+
if (mode === "new_only") {
|
|
11580
|
+
for (const rel of normalizedPaths) {
|
|
11581
|
+
const localPath = path.join(repoRoot, rel);
|
|
11582
|
+
try {
|
|
11583
|
+
await stat(localPath);
|
|
11584
|
+
return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
|
|
11585
|
+
} catch (err) {
|
|
11586
|
+
if (err.code !== "ENOENT") throw err;
|
|
11587
|
+
}
|
|
11588
|
+
}
|
|
11589
|
+
}
|
|
11590
|
+
|
|
11591
|
+
if (doCommit) {
|
|
11592
|
+
const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
11593
|
+
if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
|
|
11594
|
+
if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
|
|
11595
|
+
}
|
|
11596
|
+
|
|
11597
|
+
runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
|
|
11598
|
+
const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
|
|
11599
|
+
|
|
11600
|
+
for (const rel of normalizedPaths) {
|
|
11601
|
+
runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
|
|
11602
|
+
}
|
|
11603
|
+
|
|
11604
|
+
runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
|
|
11605
|
+
|
|
11606
|
+
const imported = [];
|
|
11607
|
+
for (const rel of normalizedPaths) {
|
|
11608
|
+
const localPath = path.join(repoRoot, rel);
|
|
11609
|
+
const info = await fileInfo(localPath, rel);
|
|
11610
|
+
const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
|
|
11611
|
+
const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
|
|
11612
|
+
imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
|
|
11613
|
+
}
|
|
11614
|
+
|
|
11615
|
+
let localCommit = null;
|
|
11616
|
+
if (doCommit) {
|
|
11617
|
+
runGit(repoRoot, ["add", "--", ...normalizedPaths]);
|
|
11618
|
+
const body = [
|
|
11619
|
+
args.message.trim(),
|
|
11620
|
+
"",
|
|
11621
|
+
"Imported from Claude.ai GitHub upload.",
|
|
11622
|
+
"",
|
|
11623
|
+
`Source remote: ${remote}`,
|
|
11624
|
+
`Source ref: ${args.ref}`,
|
|
11625
|
+
`Source commit: ${sourceCommit}`,
|
|
11626
|
+
"",
|
|
11627
|
+
"Paths:",
|
|
11628
|
+
...normalizedPaths.map((p) => `- ${p}`),
|
|
11629
|
+
].join("\n");
|
|
11630
|
+
runGit(repoRoot, ["commit", "-m", body]);
|
|
11631
|
+
localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
11632
|
+
}
|
|
11633
|
+
|
|
11634
|
+
return mcpResult(JSON.stringify({
|
|
11635
|
+
status: "ok",
|
|
11636
|
+
mode,
|
|
11637
|
+
committed: doCommit,
|
|
11638
|
+
source_commit: sourceCommit,
|
|
11639
|
+
local_commit: localCommit,
|
|
11640
|
+
imported,
|
|
11641
|
+
}, null, 2));
|
|
11642
|
+
} catch (err) {
|
|
11643
|
+
return mcpError(`Git import failed: ${err.message}`);
|
|
11644
|
+
}
|
|
11645
|
+
}
|
|
11646
|
+
|
|
11647
|
+
case "fs_list": {
|
|
11648
|
+
const dirPath = args.path || ".";
|
|
11649
|
+
const r = await resolveInMount(dirPath, args.mount, vault);
|
|
11650
|
+
if (r.error) return mcpError(r.error);
|
|
11651
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
11652
|
+
try {
|
|
11653
|
+
const entries = await readdir(r.resolved, { withFileTypes: true });
|
|
11654
|
+
const results = [];
|
|
11655
|
+
for (const entry of entries) {
|
|
11656
|
+
try {
|
|
11657
|
+
const s = await stat(path.join(r.resolved, entry.name));
|
|
11658
|
+
results.push({
|
|
11659
|
+
name: entry.name,
|
|
11660
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
11661
|
+
size: s.size,
|
|
11662
|
+
modified: s.mtime.toISOString(),
|
|
11663
|
+
});
|
|
11664
|
+
} catch {
|
|
11665
|
+
results.push({ name: entry.name, type: entry.isDirectory() ? "dir" : "file" });
|
|
11666
|
+
}
|
|
11667
|
+
}
|
|
11668
|
+
return mcpResult(JSON.stringify(results, null, 2));
|
|
11669
|
+
} catch (err) {
|
|
11670
|
+
if (err.code === "ENOENT") return mcpError(`Directory not found: ${dirPath}`);
|
|
11671
|
+
return mcpError(`List failed: ${err.message}`);
|
|
11672
|
+
}
|
|
11673
|
+
}
|
|
11674
|
+
|
|
11675
|
+
case "fs_grep": {
|
|
11676
|
+
const searchPath = args.path || ".";
|
|
11677
|
+
const r = await resolveInMount(searchPath, args.mount, vault);
|
|
11678
|
+
if (r.error) return mcpError(r.error);
|
|
11679
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
11680
|
+
|
|
11681
|
+
const maxResults = args.max_results || 50;
|
|
11682
|
+
const rgArgs = [
|
|
11683
|
+
"--no-heading", "--line-number", "--color", "never",
|
|
11684
|
+
"--max-count", String(maxResults),
|
|
11685
|
+
];
|
|
11686
|
+
if (args.context) rgArgs.push("-C", String(args.context));
|
|
11687
|
+
if (args.glob) rgArgs.push("--glob", args.glob);
|
|
11688
|
+
rgArgs.push(args.pattern, r.resolved);
|
|
11689
|
+
|
|
11690
|
+
return new Promise((resolve) => {
|
|
11691
|
+
let output = "";
|
|
11692
|
+
let killed = false;
|
|
11693
|
+
const proc = spawnProc(rgPath, rgArgs, { timeout: 15000, windowsHide: true });
|
|
11694
|
+
|
|
11695
|
+
proc.stdout.on("data", (chunk) => {
|
|
11696
|
+
output += chunk.toString();
|
|
11697
|
+
if (output.length > 65536) { // 64KB cap
|
|
11698
|
+
killed = true;
|
|
11699
|
+
proc.kill();
|
|
11700
|
+
}
|
|
11701
|
+
});
|
|
11702
|
+
proc.stderr.on("data", () => {}); // ignore stderr
|
|
11703
|
+
|
|
11704
|
+
proc.on("close", (code) => {
|
|
11705
|
+
if (killed) {
|
|
11706
|
+
resolve(mcpResult(output.slice(0, 65536) + "\n... (output truncated at 64KB)"));
|
|
11707
|
+
} else if (code === 1) {
|
|
11708
|
+
resolve(mcpResult("No matches found"));
|
|
11709
|
+
} else if (output) {
|
|
11710
|
+
// Make paths relative to mount
|
|
11711
|
+
const mountNorm = r.mount.path.replace(/\\/g, "/");
|
|
11712
|
+
const cleaned = output.replace(new RegExp(mountNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "/?", "g"), "");
|
|
11713
|
+
resolve(mcpResult(cleaned));
|
|
11714
|
+
} else {
|
|
11715
|
+
resolve(mcpResult("No matches found"));
|
|
11716
|
+
}
|
|
11717
|
+
});
|
|
11718
|
+
|
|
11719
|
+
proc.on("error", (err) => {
|
|
11720
|
+
resolve(mcpError(`Grep failed: ${err.message}`));
|
|
11721
|
+
});
|
|
11722
|
+
});
|
|
11723
|
+
}
|
|
11724
|
+
|
|
11725
|
+
case "fs_glob": {
|
|
11726
|
+
const basePath = args.path || ".";
|
|
11727
|
+
const r = await resolveInMount(basePath, args.mount, vault);
|
|
11728
|
+
if (r.error) return mcpError(r.error);
|
|
11729
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
11730
|
+
try {
|
|
11731
|
+
const matches = await fg(args.pattern, {
|
|
11732
|
+
cwd: r.resolved,
|
|
11733
|
+
dot: false,
|
|
11734
|
+
onlyFiles: true,
|
|
11735
|
+
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
11736
|
+
});
|
|
11737
|
+
if (matches.length === 0) return mcpResult("No files matched");
|
|
11738
|
+
return mcpResult(matches.sort().join("\n"));
|
|
11739
|
+
} catch (err) {
|
|
11740
|
+
return mcpError(`Glob failed: ${err.message}`);
|
|
11741
|
+
}
|
|
11742
|
+
}
|
|
11743
|
+
|
|
11744
|
+
case "fs_delete": {
|
|
11745
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11746
|
+
if (r.error) return mcpError(r.error);
|
|
11747
|
+
if (!checkAccess(r.mount, "d")) return mcpError("Delete access denied on this mount");
|
|
11748
|
+
try {
|
|
11749
|
+
const s = await stat(r.resolved);
|
|
11750
|
+
await rm(r.resolved, { recursive: s.isDirectory() });
|
|
11751
|
+
return mcpResult(`Deleted: ${args.path}`);
|
|
11752
|
+
} catch (err) {
|
|
11753
|
+
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
11754
|
+
return mcpError(`Delete failed: ${err.message}`);
|
|
11755
|
+
}
|
|
11756
|
+
}
|
|
11757
|
+
|
|
11758
|
+
case "fs_mkdir": {
|
|
11759
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11760
|
+
if (r.error) return mcpError(r.error);
|
|
11761
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11762
|
+
try {
|
|
11763
|
+
await mkdir(r.resolved, { recursive: true });
|
|
11764
|
+
return mcpResult(`Created: ${args.path}`);
|
|
11765
|
+
} catch (err) {
|
|
11766
|
+
return mcpError(`Mkdir failed: ${err.message}`);
|
|
11767
|
+
}
|
|
11768
|
+
}
|
|
11769
|
+
|
|
11770
|
+
case "fs_edit": {
|
|
11771
|
+
if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
|
|
11772
|
+
return mcpError("old_string and new_string must be strings");
|
|
11773
|
+
}
|
|
11774
|
+
if (args.old_string === args.new_string) {
|
|
11775
|
+
return mcpError("old_string and new_string must differ");
|
|
11776
|
+
}
|
|
11777
|
+
if (args.old_string.length === 0) {
|
|
11778
|
+
return mcpError("old_string must not be empty");
|
|
11779
|
+
}
|
|
11780
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
11781
|
+
if (r.error) return mcpError(r.error);
|
|
11782
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11783
|
+
try {
|
|
11784
|
+
const current = await readFile(r.resolved, "utf8");
|
|
11785
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
11786
|
+
return mcpError("Edit rejected: current file hash does not match expected_sha256");
|
|
11787
|
+
}
|
|
11788
|
+
const parts = current.split(args.old_string);
|
|
11789
|
+
const occurrences = parts.length - 1;
|
|
11790
|
+
if (occurrences === 0) {
|
|
11791
|
+
return mcpError(`Edit failed: old_string not found in ${args.path}`);
|
|
11792
|
+
}
|
|
11793
|
+
if (occurrences > 1 && !args.replace_all) {
|
|
11794
|
+
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.`);
|
|
11795
|
+
}
|
|
11796
|
+
const updated = args.replace_all
|
|
11797
|
+
? parts.join(args.new_string)
|
|
11798
|
+
: current.replace(args.old_string, args.new_string);
|
|
11799
|
+
await atomicWriteText(r.resolved, updated);
|
|
11800
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
11801
|
+
return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
|
|
11802
|
+
} catch (err) {
|
|
11803
|
+
if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
|
|
11804
|
+
return mcpError(`Edit failed: ${err.message}`);
|
|
11805
|
+
}
|
|
11806
|
+
}
|
|
11807
|
+
|
|
11808
|
+
case "fs_move": {
|
|
11809
|
+
const src = await resolveInMount(args.from, args.mount, vault);
|
|
11810
|
+
if (src.error) return mcpError(src.error);
|
|
11811
|
+
const dst = await resolveInMount(args.to, args.mount, vault);
|
|
11812
|
+
if (dst.error) return mcpError(dst.error);
|
|
11813
|
+
if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
|
|
11814
|
+
return mcpError("Move requires write+delete access on this mount");
|
|
11815
|
+
}
|
|
11816
|
+
try {
|
|
11817
|
+
await stat(src.resolved);
|
|
11818
|
+
} catch (err) {
|
|
11819
|
+
if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
|
|
11820
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
11821
|
+
}
|
|
11822
|
+
let dstExists = false;
|
|
11823
|
+
try {
|
|
11824
|
+
await stat(dst.resolved);
|
|
11825
|
+
dstExists = true;
|
|
11826
|
+
} catch (err) {
|
|
11827
|
+
if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
|
|
11828
|
+
}
|
|
11829
|
+
if (dstExists && !args.overwrite) {
|
|
11830
|
+
return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
|
|
11831
|
+
}
|
|
11832
|
+
try {
|
|
11833
|
+
await mkdir(path.dirname(dst.resolved), { recursive: true });
|
|
11834
|
+
if (dstExists && args.overwrite) {
|
|
11835
|
+
const dstStat = await stat(dst.resolved);
|
|
11836
|
+
await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
|
|
11837
|
+
}
|
|
11838
|
+
try {
|
|
11839
|
+
await rename(src.resolved, dst.resolved);
|
|
11840
|
+
} catch (err) {
|
|
11841
|
+
if (err.code === "EXDEV") {
|
|
11842
|
+
await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
|
|
11843
|
+
const srcStat = await stat(src.resolved);
|
|
11844
|
+
await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
|
|
11845
|
+
} else {
|
|
11846
|
+
throw err;
|
|
11847
|
+
}
|
|
11848
|
+
}
|
|
11849
|
+
return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
|
|
11850
|
+
} catch (err) {
|
|
11851
|
+
return mcpError(`Move failed: ${err.message}`);
|
|
11852
|
+
}
|
|
11853
|
+
}
|
|
11854
|
+
|
|
11855
|
+
case "fs_copy": {
|
|
11856
|
+
const src = await resolveInMount(args.from, args.mount, vault);
|
|
11857
|
+
if (src.error) return mcpError(src.error);
|
|
11858
|
+
const dst = await resolveInMount(args.to, args.mount, vault);
|
|
11859
|
+
if (dst.error) return mcpError(dst.error);
|
|
11860
|
+
if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
|
|
11861
|
+
if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
|
|
11862
|
+
let srcStat;
|
|
11863
|
+
try {
|
|
11864
|
+
srcStat = await stat(src.resolved);
|
|
11865
|
+
} catch (err) {
|
|
11866
|
+
if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
|
|
11867
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
11868
|
+
}
|
|
11869
|
+
let dstExists = false;
|
|
11870
|
+
try {
|
|
11871
|
+
await stat(dst.resolved);
|
|
11872
|
+
dstExists = true;
|
|
11873
|
+
} catch (err) {
|
|
11874
|
+
if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
|
|
11875
|
+
}
|
|
11876
|
+
if (dstExists && !args.overwrite) {
|
|
11877
|
+
return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
|
|
11878
|
+
}
|
|
11879
|
+
try {
|
|
11880
|
+
await mkdir(path.dirname(dst.resolved), { recursive: true });
|
|
11881
|
+
await cp(src.resolved, dst.resolved, {
|
|
11882
|
+
recursive: true,
|
|
11883
|
+
errorOnExist: false,
|
|
11884
|
+
force: !!args.overwrite,
|
|
11885
|
+
});
|
|
11886
|
+
return mcpResult(JSON.stringify({
|
|
11887
|
+
status: "copied",
|
|
11888
|
+
from: args.from,
|
|
11889
|
+
to: args.to,
|
|
11890
|
+
type: srcStat.isDirectory() ? "dir" : "file",
|
|
11891
|
+
}, null, 2));
|
|
11892
|
+
} catch (err) {
|
|
11893
|
+
return mcpError(`Copy failed: ${err.message}`);
|
|
11894
|
+
}
|
|
11895
|
+
}
|
|
11896
|
+
|
|
11897
|
+
case "fs_mounts": {
|
|
11898
|
+
const { mounts, error } = await getFileserverMounts(vault);
|
|
11899
|
+
if (error) return mcpError(error);
|
|
11900
|
+
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)");
|
|
11901
|
+
// Decode the access string so callers know what's possible BEFORE trying —
|
|
11902
|
+
// notably `git` (the git verbs require it; absent = explain how to enable).
|
|
11903
|
+
const decorated = mounts.map((m) => {
|
|
11904
|
+
const a = String(m.access || "");
|
|
11905
|
+
return { ...m, can: { read: a.includes("r"), write: a.includes("w"), delete: a.includes("d"), git: a.includes("g") } };
|
|
11906
|
+
});
|
|
11907
|
+
return mcpResult(JSON.stringify(decorated, null, 2));
|
|
11908
|
+
}
|
|
11909
|
+
|
|
11910
|
+
case "fs_repo_status": {
|
|
11911
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
11912
|
+
if (r.error) return mcpError(r.error);
|
|
11913
|
+
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.");
|
|
11914
|
+
try {
|
|
11915
|
+
const { result, error } = await fsGit.repoStatus(r.resolved);
|
|
11916
|
+
if (error) return mcpError(error);
|
|
11917
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
11918
|
+
} catch (err) {
|
|
11919
|
+
return mcpError(`Status failed: ${err.message}`);
|
|
11920
|
+
}
|
|
11921
|
+
}
|
|
11922
|
+
|
|
11923
|
+
case "fs_use_branch": {
|
|
11924
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
11925
|
+
if (r.error) return mcpError(r.error);
|
|
11926
|
+
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.");
|
|
11927
|
+
try {
|
|
11928
|
+
const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
|
|
11929
|
+
if (error) return mcpError(error);
|
|
11930
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
11931
|
+
} catch (err) {
|
|
11932
|
+
return mcpError(`Branch switch failed: ${err.message}`);
|
|
11933
|
+
}
|
|
11934
|
+
}
|
|
11935
|
+
|
|
11936
|
+
case "fs_commit": {
|
|
11937
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
11938
|
+
if (r.error) return mcpError(r.error);
|
|
11939
|
+
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.");
|
|
11940
|
+
const push = args.push !== false;
|
|
11941
|
+
const dryRun = args.dry_run === true;
|
|
11942
|
+
// Fetch the push token from the vault up front (commit happens first, so
|
|
11943
|
+
// a missing token still preserves the local commit). Skipped on dry-run.
|
|
11944
|
+
let token = null, tokenError = null;
|
|
11945
|
+
if (push && !dryRun) {
|
|
11946
|
+
const sec = await vaultRetrieveValue(vault, "github");
|
|
11947
|
+
if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
|
|
11948
|
+
else token = sec.value;
|
|
11949
|
+
}
|
|
11950
|
+
try {
|
|
11951
|
+
const { result, error } = await fsGit.commit(r.resolved, {
|
|
11952
|
+
message: args.message,
|
|
11953
|
+
paths: args.paths,
|
|
11954
|
+
push,
|
|
11955
|
+
dryRun,
|
|
11956
|
+
expectedHead: args.expected_head,
|
|
11957
|
+
remote: args.remote,
|
|
11958
|
+
token,
|
|
11959
|
+
tokenError,
|
|
11960
|
+
authorName: args.author_name,
|
|
11961
|
+
authorEmail: args.author_email,
|
|
11962
|
+
});
|
|
11963
|
+
if (error) return mcpError(error);
|
|
11964
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
11965
|
+
} catch (err) {
|
|
11966
|
+
return mcpError(`Commit failed: ${err.message}`);
|
|
11967
|
+
}
|
|
11968
|
+
}
|
|
11969
|
+
|
|
11970
|
+
case "fs_diff": {
|
|
11971
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
11972
|
+
if (r.error) return mcpError(r.error);
|
|
11973
|
+
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.");
|
|
11974
|
+
try {
|
|
11975
|
+
const { result, error } = await fsGit.diff(r.resolved, { paths: args.paths, ref: args.ref, staged: args.staged === true });
|
|
11976
|
+
if (error) return mcpError(error);
|
|
11977
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
11978
|
+
} catch (err) {
|
|
11979
|
+
return mcpError(`Diff failed: ${err.message}`);
|
|
11980
|
+
}
|
|
11981
|
+
}
|
|
11326
11982
|
|
|
11327
11983
|
case "call_agent": {
|
|
11328
11984
|
const result = await runCallAgent(args || {});
|
|
@@ -11511,6 +12167,271 @@ async function handleMcpTool(vault, name, args) {
|
|
|
11511
12167
|
return mcpResult(JSON.stringify(result));
|
|
11512
12168
|
}
|
|
11513
12169
|
|
|
12170
|
+
case "fs_exec": {
|
|
12171
|
+
const r = await resolveInMount(args.cwd || ".", args.mount, vault);
|
|
12172
|
+
if (r.error) return mcpError(r.error);
|
|
12173
|
+
const EXEC_ALLOWLIST = [
|
|
12174
|
+
"git","pnpm","npx","node","python","python3","rclone","bash",
|
|
12175
|
+
"cat","grep","find","wc","sha256sum","date","echo","tsc","eslint",
|
|
12176
|
+
"ping","where","which","ls","dir","head","tail","sort","uniq","diff","curl",
|
|
12177
|
+
"pip","docker","docker-compose","pm2","cloudflared",
|
|
12178
|
+
"ssh-keygen","tar","netstat",
|
|
12179
|
+
"get-filehash","get-content","select-string","test-path",
|
|
12180
|
+
"get-childitem","measure-object","certutil","pwsh",
|
|
12181
|
+
"copy-item","move-item","remove-item","new-item",
|
|
12182
|
+
"rename-item","set-content","add-content","out-file",
|
|
12183
|
+
"convertto-json","convertfrom-json","select-xml",
|
|
12184
|
+
"invoke-webrequest",
|
|
12185
|
+
"format-table","format-list",
|
|
12186
|
+
"sort-object","where-object","group-object",
|
|
12187
|
+
"write-output","out-string",
|
|
12188
|
+
"get-nettcpconnection","get-process","stop-process",
|
|
12189
|
+
"get-service","get-eventlog","get-date",
|
|
12190
|
+
"compress-archive","expand-archive",
|
|
12191
|
+
];
|
|
12192
|
+
const EXEC_BLOCKED = ["invoke-expression","iex","start-process","set-executionpolicy","reg","regedit","format","shutdown","restart-computer","reboot","mkfs","dd","cmd","del"];
|
|
12193
|
+
const MAX_STDOUT = 102400;
|
|
12194
|
+
if (!Array.isArray(args.command) || args.command.length === 0) return mcpError("command must be a non-empty array");
|
|
12195
|
+
const cmd0 = path.basename(args.command[0]).replace(/\.exe$/i, "").toLowerCase();
|
|
12196
|
+
if (EXEC_BLOCKED.includes(cmd0)) return mcpError(`Blocked command: ${cmd0}`);
|
|
12197
|
+
if (!EXEC_ALLOWLIST.includes(cmd0)) return mcpError(`Command not in allowlist: ${cmd0}. Allowed: ${EXEC_ALLOWLIST.join(", ")}`);
|
|
12198
|
+
if (args.command.includes("--force") && cmd0 === "git" && args.command.includes("push")) return mcpError("Force-push blocked");
|
|
12199
|
+
if (cmd0 === "git" && args.command.includes("push") && (args.command.includes("main") || args.command.includes("master"))) return mcpError("Push to main/master blocked");
|
|
12200
|
+
const timeout = Math.min(Number(args.timeout_seconds || 30), 300) * 1000;
|
|
12201
|
+
const startTime = Date.now();
|
|
12202
|
+
try {
|
|
12203
|
+
const { execFile, exec } = await import("child_process");
|
|
12204
|
+
const result = await new Promise((resolve, reject) => {
|
|
12205
|
+
const execEnv = { ...process.env, ...(args.env || {}) };
|
|
12206
|
+
if (os.platform() === "win32") {
|
|
12207
|
+
const extra = ["C:\\Program Files\\Git\\cmd", "C:\\Program Files\\Git\\bin", "C:\\Program Files\\nodejs", process.env.APPDATA ? process.env.APPDATA + "\\npm" : ""].filter(Boolean).join(";");
|
|
12208
|
+
const pathKey = Object.keys(execEnv).find(k => k.toUpperCase() === "PATH") || "Path";
|
|
12209
|
+
execEnv[pathKey] = extra + ";" + (execEnv[pathKey] || "");
|
|
12210
|
+
}
|
|
12211
|
+
const useShell = args.shell || os.platform() === "win32";
|
|
12212
|
+
const shellBin = os.platform() === "win32" ? "pwsh.exe" : true;
|
|
12213
|
+
const opts = { cwd: r.resolved, timeout, maxBuffer: MAX_STDOUT, windowsHide: true, env: execEnv, shell: useShell ? shellBin : false };
|
|
12214
|
+
const cb = (err, stdout, stderr) => {
|
|
12215
|
+
const duration = Date.now() - startTime;
|
|
12216
|
+
const truncated = (stdout?.length || 0) >= MAX_STDOUT || (stderr?.length || 0) >= MAX_STDOUT;
|
|
12217
|
+
if (err && !err.killed) resolve({ stdout: stdout || "", stderr: stderr || err.message, exit_code: err.code || 1, duration_ms: duration, truncated });
|
|
12218
|
+
else if (err?.killed) resolve({ stdout: stdout || "", stderr: "Process killed (timeout)", exit_code: 137, duration_ms: duration, truncated: true });
|
|
12219
|
+
else resolve({ stdout: stdout || "", stderr: stderr || "", exit_code: 0, duration_ms: duration, truncated });
|
|
12220
|
+
};
|
|
12221
|
+
const psEscape = (s) => "'" + s.replace(/'/g, "''") + "'";
|
|
12222
|
+
if (os.platform() === "win32") {
|
|
12223
|
+
const psCmd = args.shell
|
|
12224
|
+
? args.command.join(" ")
|
|
12225
|
+
: "& " + args.command.map(psEscape).join(" ");
|
|
12226
|
+
exec(psCmd, opts, cb);
|
|
12227
|
+
} else if (useShell) {
|
|
12228
|
+
exec(args.command.join(" "), opts, cb);
|
|
12229
|
+
} else {
|
|
12230
|
+
execFile(args.command[0], args.command.slice(1), opts, cb);
|
|
12231
|
+
}
|
|
12232
|
+
});
|
|
12233
|
+
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";
|
|
12234
|
+
try { await appendFile(path.join(os.tmpdir(), "fs-exec-audit.jsonl"), auditLine); } catch {}
|
|
12235
|
+
return mcpResult(JSON.stringify(result));
|
|
12236
|
+
} catch (err) {
|
|
12237
|
+
return mcpError(`exec failed: ${err.message}`);
|
|
12238
|
+
}
|
|
12239
|
+
}
|
|
12240
|
+
|
|
12241
|
+
case "fs_hash": {
|
|
12242
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
12243
|
+
if (r.error) return mcpError(r.error);
|
|
12244
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
|
|
12245
|
+
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
12246
|
+
if (args.paths.length > 50) return mcpError("Max 50 paths per call");
|
|
12247
|
+
const algo = args.algorithm || "sha256";
|
|
12248
|
+
if (!["sha256", "md5", "sha1"].includes(algo)) return mcpError("algorithm must be sha256, md5, or sha1");
|
|
12249
|
+
const results = [];
|
|
12250
|
+
for (const p of args.paths) {
|
|
12251
|
+
const fp = await resolveInMount(p, args.mount, vault);
|
|
12252
|
+
if (fp.error) { results.push({ path: p, error: fp.error }); continue; }
|
|
12253
|
+
try {
|
|
12254
|
+
const content = await readFile(fp.resolved);
|
|
12255
|
+
const hash = crypto.createHash(algo).update(content).digest("hex");
|
|
12256
|
+
results.push({ path: p, hash, bytes: content.length });
|
|
12257
|
+
} catch (err) {
|
|
12258
|
+
results.push({ path: p, error: err.message });
|
|
12259
|
+
}
|
|
12260
|
+
}
|
|
12261
|
+
return mcpResult(JSON.stringify({ results }));
|
|
12262
|
+
}
|
|
12263
|
+
|
|
12264
|
+
case "fs_cat_lines": {
|
|
12265
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
12266
|
+
if (r.error) return mcpError(r.error);
|
|
12267
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
|
|
12268
|
+
try {
|
|
12269
|
+
const content = await readFile(r.resolved, "utf8");
|
|
12270
|
+
const allLines = content.split("\n");
|
|
12271
|
+
const start = Math.max(1, Math.floor(args.start_line)) - 1;
|
|
12272
|
+
const end = args.end_line ? Math.min(Math.floor(args.end_line), allLines.length) : allLines.length;
|
|
12273
|
+
const selected = allLines.slice(start, end);
|
|
12274
|
+
return mcpResult(JSON.stringify({ path: args.path, start: start + 1, end, total_lines: allLines.length, content: selected.join("\n") }));
|
|
12275
|
+
} catch (err) {
|
|
12276
|
+
return mcpError(`Read failed: ${err.message}`);
|
|
12277
|
+
}
|
|
12278
|
+
}
|
|
12279
|
+
|
|
12280
|
+
case "fs_grep_json": {
|
|
12281
|
+
const searchPath = args.path || ".";
|
|
12282
|
+
const r = await resolveInMount(searchPath, args.mount, vault);
|
|
12283
|
+
if (r.error) return mcpError(r.error);
|
|
12284
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
|
|
12285
|
+
const maxResults = Math.min(Number(args.max_results || 50), 200);
|
|
12286
|
+
const ctx = Math.min(Number(args.context_lines || 0), 10);
|
|
12287
|
+
try {
|
|
12288
|
+
const rgArgs = ["--json", "-e", args.pattern, "--max-count", String(maxResults)];
|
|
12289
|
+
if (ctx > 0) rgArgs.push("-C", String(ctx));
|
|
12290
|
+
if (args.glob) rgArgs.push("-g", args.glob);
|
|
12291
|
+
rgArgs.push(r.resolved);
|
|
12292
|
+
const raw = execSyncTop(`"${rgPath}" ${rgArgs.map(a => `"${a}"`).join(" ")}`, { encoding: "utf8", timeout: 30000, windowsHide: true, maxBuffer: 1024 * 1024 });
|
|
12293
|
+
const matches = [];
|
|
12294
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
12295
|
+
try {
|
|
12296
|
+
const obj = JSON.parse(line);
|
|
12297
|
+
if (obj.type === "match") {
|
|
12298
|
+
const rel = path.relative(r.resolved, obj.data.path.text).replace(/\\/g, "/");
|
|
12299
|
+
matches.push({ file: rel, line: obj.data.line_number, match: obj.data.lines.text.trimEnd() });
|
|
12300
|
+
}
|
|
12301
|
+
} catch {}
|
|
12302
|
+
}
|
|
12303
|
+
return mcpResult(JSON.stringify({ matches, total_matches: matches.length, truncated: matches.length >= maxResults }));
|
|
12304
|
+
} catch (err) {
|
|
12305
|
+
if (err.status === 1) return mcpResult(JSON.stringify({ matches: [], total_matches: 0, truncated: false }));
|
|
12306
|
+
return mcpError(`grep failed: ${err.message}`);
|
|
12307
|
+
}
|
|
12308
|
+
}
|
|
12309
|
+
|
|
12310
|
+
case "fs_search": {
|
|
12311
|
+
const searchBase = args.path || ".";
|
|
12312
|
+
const r = await resolveInMount(searchBase, args.mount, vault);
|
|
12313
|
+
if (r.error) return mcpError(r.error);
|
|
12314
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied");
|
|
12315
|
+
const maxResults = Math.min(Number(args.max_results || 50), 200);
|
|
12316
|
+
try {
|
|
12317
|
+
const globPattern = args.query || (args.file_types ? `**/*{${args.file_types.join(",")}}` : "**/*");
|
|
12318
|
+
const files = await fg(globPattern, { cwd: r.resolved, absolute: true, stats: true, dot: false, ignore: ["**/node_modules/**", "**/.next/**", "**/.git/**"] });
|
|
12319
|
+
let results = [];
|
|
12320
|
+
for (const f of files) {
|
|
12321
|
+
const st = f.stats || {};
|
|
12322
|
+
const mtime = st.mtime ? new Date(st.mtime) : null;
|
|
12323
|
+
if (args.modified_after && mtime && mtime < new Date(args.modified_after)) continue;
|
|
12324
|
+
if (args.modified_before && mtime && mtime > new Date(args.modified_before)) continue;
|
|
12325
|
+
const rel = path.relative(r.resolved, f.path).replace(/\\/g, "/");
|
|
12326
|
+
const entry = { path: rel, size: st.size || 0, modified: mtime?.toISOString() || null };
|
|
12327
|
+
if (args.content_pattern) {
|
|
12328
|
+
try {
|
|
12329
|
+
const text = await readFile(f.path, "utf8");
|
|
12330
|
+
const match = text.match(new RegExp(args.content_pattern));
|
|
12331
|
+
if (!match) continue;
|
|
12332
|
+
const lineIdx = text.substring(0, match.index).split("\n").length;
|
|
12333
|
+
entry.match_line = lineIdx;
|
|
12334
|
+
entry.match_text = match[0].slice(0, 200);
|
|
12335
|
+
} catch { continue; }
|
|
12336
|
+
}
|
|
12337
|
+
results.push(entry);
|
|
12338
|
+
if (results.length >= maxResults) break;
|
|
12339
|
+
}
|
|
12340
|
+
return mcpResult(JSON.stringify({ results, total: results.length }));
|
|
12341
|
+
} catch (err) {
|
|
12342
|
+
return mcpError(`search failed: ${err.message}`);
|
|
12343
|
+
}
|
|
12344
|
+
}
|
|
12345
|
+
|
|
12346
|
+
case "fs_put": {
|
|
12347
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
12348
|
+
if (r.error) return mcpError(r.error);
|
|
12349
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied");
|
|
12350
|
+
try {
|
|
12351
|
+
const exists = await stat(r.resolved).then(() => true).catch(() => false);
|
|
12352
|
+
if (exists && !args.overwrite) return mcpError(`File exists: ${args.path} (set overwrite=true)`);
|
|
12353
|
+
const buf = Buffer.from(args.content_base64, "base64");
|
|
12354
|
+
const dir = path.dirname(r.resolved);
|
|
12355
|
+
await mkdir(dir, { recursive: true });
|
|
12356
|
+
await atomicWriteText(r.resolved, buf);
|
|
12357
|
+
const hash = crypto.createHash("sha256").update(buf).digest("hex");
|
|
12358
|
+
return mcpResult(JSON.stringify({ path: args.path, bytes: buf.length, sha256: hash }));
|
|
12359
|
+
} catch (err) {
|
|
12360
|
+
return mcpError(`Put failed: ${err.message}`);
|
|
12361
|
+
}
|
|
12362
|
+
}
|
|
12363
|
+
|
|
12364
|
+
case "fs_dav_setup": {
|
|
12365
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
12366
|
+
const ttl = Math.min(Number(args.ttl_seconds || 14400), 86400);
|
|
12367
|
+
|
|
12368
|
+
const startResult = await webdavService.ensureRunning(() => vault);
|
|
12369
|
+
if (startResult.error) return mcpError(`WebDAV setup failed: ${startResult.error}`);
|
|
12370
|
+
|
|
12371
|
+
const wdStatus = webdavService.getStatus();
|
|
12372
|
+
if (wdStatus.status === "error") {
|
|
12373
|
+
return mcpError(
|
|
12374
|
+
`WebDAV child error: ${wdStatus.error}\n` +
|
|
12375
|
+
`Restarts so far: ${wdStatus.restart_count}. Retry or check clauth logs.`
|
|
12376
|
+
);
|
|
12377
|
+
}
|
|
12378
|
+
|
|
12379
|
+
// Fetch plaintext credential — the sandbox's own rclone obscures it in the script
|
|
12380
|
+
let plainPass = "";
|
|
12381
|
+
try {
|
|
12382
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
12383
|
+
const cr = await api.retrieve(vault.password, vault.machineHash, token, timestamp, "webdav-claude");
|
|
12384
|
+
if (cr.value) plainPass = cr.value;
|
|
12385
|
+
} catch {}
|
|
12386
|
+
|
|
12387
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
12388
|
+
|
|
12389
|
+
const setupScript = [
|
|
12390
|
+
"#!/usr/bin/env bash",
|
|
12391
|
+
"# WebDAV setup — generated by fs_dav_setup (clauth)",
|
|
12392
|
+
`# Expires hint: ${expiresAt}`,
|
|
12393
|
+
"set -e",
|
|
12394
|
+
"",
|
|
12395
|
+
"# Configure rclone WebDAV remote via env vars (no config file, no mount, no daemon)",
|
|
12396
|
+
"export RCLONE_CONFIG_DAV_TYPE=webdav",
|
|
12397
|
+
"export RCLONE_CONFIG_DAV_URL=https://dav.regendevcorp.com",
|
|
12398
|
+
"export RCLONE_CONFIG_DAV_VENDOR=rclone",
|
|
12399
|
+
"export RCLONE_CONFIG_DAV_USER=claude",
|
|
12400
|
+
plainPass
|
|
12401
|
+
? `export RCLONE_CONFIG_DAV_PASS=$(rclone obscure "${plainPass}")`
|
|
12402
|
+
: "# RCLONE_CONFIG_DAV_PASS — no credential found in vault",
|
|
12403
|
+
"",
|
|
12404
|
+
"# Verify connectivity",
|
|
12405
|
+
"rclone lsd dav: --quiet",
|
|
12406
|
+
'echo "DAV ready — use rclone commands directly:"',
|
|
12407
|
+
'echo " rclone lsd dav:corpus/ # list dirs"',
|
|
12408
|
+
'echo " rclone cat dav:corpus/file.md # read file"',
|
|
12409
|
+
'echo " rclone rcat dav:dev/file.txt <<< content # write file"',
|
|
12410
|
+
'echo " rclone copy local/ dav:dev/path/ # upload"',
|
|
12411
|
+
'echo " rclone cat dav:file | grep pattern # search"',
|
|
12412
|
+
].join("\n");
|
|
12413
|
+
|
|
12414
|
+
return mcpResult(JSON.stringify({
|
|
12415
|
+
status: wdStatus.status,
|
|
12416
|
+
url: "https://dav.regendevcorp.com",
|
|
12417
|
+
setup_script: setupScript,
|
|
12418
|
+
expires_at: expiresAt,
|
|
12419
|
+
ttl_seconds: ttl,
|
|
12420
|
+
webdav_user: "claude",
|
|
12421
|
+
started_at: wdStatus.started_at,
|
|
12422
|
+
mounts: (webdavService.loadConfig().upstreams || []).map(u => ({
|
|
12423
|
+
rclone_path: `dav:${u.name}/`,
|
|
12424
|
+
host_path: u.path,
|
|
12425
|
+
description: u.name === "corpus" ? "Global corpus ($CORPUS_ROOT on host — Google Drive)" : `Local filesystem (${u.path} on host)`,
|
|
12426
|
+
})),
|
|
12427
|
+
deprecated_tools: ["fs_read", "fs_list", "fs_stat", "fs_edit", "fs_mkdir", "fs_use_branch"],
|
|
12428
|
+
deprecated_note: "Use WebDAV (rclone cat/ls/lsd) instead — 37-56x faster. These tools remain as fallback.",
|
|
12429
|
+
exec_enabled: true,
|
|
12430
|
+
exec_allowlist: ["git", "pnpm", "npx", "node", "python3", "rclone", "bash", "cat", "grep", "find", "wc", "sha256sum", "tsc", "eslint"],
|
|
12431
|
+
new_tools: ["fs_exec", "fs_hash", "fs_cat_lines", "fs_grep_json", "fs_search", "fs_put"],
|
|
12432
|
+
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.",
|
|
12433
|
+
}, null, 2));
|
|
12434
|
+
}
|
|
11514
12435
|
|
|
11515
12436
|
default:
|
|
11516
12437
|
return mcpError(`Unknown tool: ${name}`);
|
|
@@ -11866,16 +12787,10 @@ async function installMacOS(pw, tunnelHostname, execSync) {
|
|
|
11866
12787
|
try {
|
|
11867
12788
|
// Delete existing entry if present (ignore errors)
|
|
11868
12789
|
try {
|
|
11869
|
-
|
|
11870
|
-
`security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
|
|
11871
|
-
{ encoding: "utf8", stdio: "pipe" }
|
|
11872
|
-
);
|
|
12790
|
+
execFileSync("security", ["delete-generic-password", "-s", keychainService, "-a", keychainAccount], { encoding: "utf8", stdio: "pipe" });
|
|
11873
12791
|
} catch {}
|
|
11874
12792
|
|
|
11875
|
-
|
|
11876
|
-
`security add-generic-password -s "${keychainService}" -a "${keychainAccount}" -w "${pw.replace(/"/g, '\\"')}" -U`,
|
|
11877
|
-
{ encoding: "utf8", stdio: "pipe" }
|
|
11878
|
-
);
|
|
12793
|
+
execFileSync("security", ["add-generic-password", "-s", keychainService, "-a", keychainAccount, "-w", pw, "-U"], { encoding: "utf8", stdio: "pipe" });
|
|
11879
12794
|
spinner.succeed(chalk.green("Password stored in Keychain"));
|
|
11880
12795
|
} catch (err) {
|
|
11881
12796
|
spinner.fail(chalk.red(`Keychain storage failed: ${err.message}`));
|
|
@@ -11888,7 +12803,7 @@ async function installMacOS(pw, tunnelHostname, execSync) {
|
|
|
11888
12803
|
|
|
11889
12804
|
// Build shell command that reads from Keychain and starts clauth
|
|
11890
12805
|
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
11891
|
-
const shellCmd = `PW=$(security find-generic-password -s "${keychainService}" -a "${keychainAccount}" -w) && exec "${nodeExe}" "${cliEntry}" serve start -
|
|
12806
|
+
const shellCmd = `PW=$(security find-generic-password -s "${keychainService}" -a "${keychainAccount}" -w) && export CLAUTH_BOOT_PASSWORD="$PW" && exec "${nodeExe}" "${cliEntry}" serve start --pw-env${tunnelArg}`;
|
|
11892
12807
|
|
|
11893
12808
|
// Create LaunchAgent plist
|
|
11894
12809
|
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -11949,29 +12864,34 @@ async function installLinux(pw, tunnelHostname, execSync) {
|
|
|
11949
12864
|
let useSecretTool = false;
|
|
11950
12865
|
const spinner = ora("Storing password securely...").start();
|
|
11951
12866
|
|
|
11952
|
-
|
|
11953
|
-
execSync("which secret-tool", { encoding: "utf8", stdio: "pipe" });
|
|
11954
|
-
execSync(
|
|
11955
|
-
`echo -n "${pw.replace(/"/g, '\\"')}" | secret-tool store --label="clauth daemon password" service clauth account daemon`,
|
|
11956
|
-
{ encoding: "utf8", stdio: "pipe" }
|
|
11957
|
-
);
|
|
11958
|
-
useSecretTool = true;
|
|
11959
|
-
spinner.succeed(chalk.green("Password stored via secret-tool (GNOME Keyring)"));
|
|
11960
|
-
} catch {
|
|
11961
|
-
// Fallback: encrypt with openssl and store in file
|
|
11962
|
-
// Uses a key derived from machine-id for basic protection at rest
|
|
12867
|
+
if (pw) {
|
|
11963
12868
|
try {
|
|
11964
|
-
|
|
11965
|
-
|
|
11966
|
-
|
|
11967
|
-
|
|
11968
|
-
|
|
11969
|
-
|
|
11970
|
-
|
|
11971
|
-
|
|
11972
|
-
|
|
11973
|
-
|
|
12869
|
+
execSync("which secret-tool", { encoding: "utf8", stdio: "pipe" });
|
|
12870
|
+
execFileSync("secret-tool", ["store", "--label=clauth daemon password", "service", "clauth", "account", "daemon"], {
|
|
12871
|
+
input: pw,
|
|
12872
|
+
encoding: "utf8",
|
|
12873
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12874
|
+
});
|
|
12875
|
+
useSecretTool = true;
|
|
12876
|
+
spinner.succeed(chalk.green("Password stored via secret-tool (GNOME Keyring)"));
|
|
12877
|
+
} catch {
|
|
12878
|
+
// Fallback: encrypt with openssl and store in file.
|
|
12879
|
+
try {
|
|
12880
|
+
const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim();
|
|
12881
|
+
const encrypted = execFileSync("openssl", ["enc", "-aes-256-cbc", "-pbkdf2", "-iter", "100000", "-pass", `pass:${machineId}`, "-base64"], {
|
|
12882
|
+
input: pw,
|
|
12883
|
+
encoding: "utf8",
|
|
12884
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12885
|
+
}).trim();
|
|
12886
|
+
fs.writeFileSync(bootKeyPath, encrypted, { mode: 0o600 });
|
|
12887
|
+
spinner.succeed(chalk.green("Password encrypted -> boot.key (openssl fallback)"));
|
|
12888
|
+
} catch (err) {
|
|
12889
|
+
spinner.fail(chalk.red(`Password storage failed: ${err.message}`));
|
|
12890
|
+
process.exit(1);
|
|
12891
|
+
}
|
|
11974
12892
|
}
|
|
12893
|
+
} else {
|
|
12894
|
+
spinner.succeed(chalk.yellow("No password supplied; daemon will start locked"));
|
|
11975
12895
|
}
|
|
11976
12896
|
|
|
11977
12897
|
// Find the node executable and cli entry
|
|
@@ -11981,13 +12901,18 @@ async function installLinux(pw, tunnelHostname, execSync) {
|
|
|
11981
12901
|
// Build the ExecStart command
|
|
11982
12902
|
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
11983
12903
|
let execStart;
|
|
11984
|
-
if (
|
|
12904
|
+
if (!pw) {
|
|
12905
|
+
const wrapperPath = path.join(configDir, "start.sh");
|
|
12906
|
+
fs.writeFileSync(wrapperPath, `#!/bin/sh\nexec "${nodeExe}" "${cliEntry}" serve start${tunnelArg}\n`, { mode: 0o700 });
|
|
12907
|
+
execStart = `/bin/sh ${wrapperPath}`;
|
|
12908
|
+
} else if (useSecretTool) {
|
|
11985
12909
|
// Create a wrapper script that reads from secret-tool
|
|
11986
12910
|
const wrapperPath = path.join(configDir, "start.sh");
|
|
11987
12911
|
const wrapperContent = [
|
|
11988
12912
|
"#!/bin/sh",
|
|
11989
12913
|
`PW=$(secret-tool lookup service clauth account daemon)`,
|
|
11990
|
-
|
|
12914
|
+
"export CLAUTH_BOOT_PASSWORD=\"$PW\"",
|
|
12915
|
+
`exec "${nodeExe}" "${cliEntry}" serve start --pw-env${tunnelArg}`,
|
|
11991
12916
|
].join("\n");
|
|
11992
12917
|
fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
|
|
11993
12918
|
execStart = `/bin/sh ${wrapperPath}`;
|
|
@@ -11998,7 +12923,8 @@ async function installLinux(pw, tunnelHostname, execSync) {
|
|
|
11998
12923
|
"#!/bin/sh",
|
|
11999
12924
|
`MACHINE_ID=$(cat /etc/machine-id)`,
|
|
12000
12925
|
`PW=$(openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"$MACHINE_ID" -base64 -d < "${bootKeyPath}")`,
|
|
12001
|
-
|
|
12926
|
+
"export CLAUTH_BOOT_PASSWORD=\"$PW\"",
|
|
12927
|
+
`exec "${nodeExe}" "${cliEntry}" serve start --pw-env${tunnelArg}`,
|
|
12002
12928
|
].join("\n");
|
|
12003
12929
|
fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
|
|
12004
12930
|
execStart = `/bin/sh ${wrapperPath}`;
|
|
@@ -12037,7 +12963,7 @@ WantedBy=default.target
|
|
|
12037
12963
|
|
|
12038
12964
|
console.log(chalk.cyan("\n Auto-start installed (Linux):\n"));
|
|
12039
12965
|
console.log(chalk.gray(` service: ${serviceFile}`));
|
|
12040
|
-
console.log(chalk.gray(` password: ${useSecretTool ? "GNOME Keyring" : bootKeyPath}`));
|
|
12966
|
+
console.log(chalk.gray(` password: ${pw ? (useSecretTool ? "GNOME Keyring" : bootKeyPath) : "locked (not persisted)"}`));
|
|
12041
12967
|
if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
|
|
12042
12968
|
console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
|
|
12043
12969
|
}
|