@lifeaitools/clauth 1.5.84 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/serve.js +1133 -747
- package/cli/commands/watchdog.js +79 -14
- package/cli/index.js +19 -6
- package/cli/lib/fs-git.js +217 -0
- package/cli/recovery.js +101 -0
- package/cli/watchdog-registry.js +209 -0
- package/cli/watchdog-registry.test.js +89 -0
- 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.js
CHANGED
|
@@ -17,10 +17,17 @@ import ora from "ora";
|
|
|
17
17
|
import { execSync as execSyncTop } from "child_process";
|
|
18
18
|
import Conf from "conf";
|
|
19
19
|
import { getConfOptions } from "../conf-path.js";
|
|
20
|
-
import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename } from "node:fs/promises";
|
|
20
|
+
import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
|
|
21
21
|
import fg from "fast-glob";
|
|
22
22
|
import { rgPath } from "@vscode/ripgrep";
|
|
23
23
|
import { createStudioDebugRuntime } from "../studio-debug.js";
|
|
24
|
+
import { writeCredentialWithRecovery } from "../recovery.js";
|
|
25
|
+
import * as fsGit from "../lib/fs-git.js";
|
|
26
|
+
import {
|
|
27
|
+
getWatchdogStatuses,
|
|
28
|
+
readWatchdogEvents,
|
|
29
|
+
restartWatchdogService,
|
|
30
|
+
} from "../watchdog-registry.js";
|
|
24
31
|
|
|
25
32
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
33
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
|
@@ -309,9 +316,14 @@ function createRotationEngine(password, machineHash, logFile) {
|
|
|
309
316
|
const result = await config.rotate(currentKey);
|
|
310
317
|
if (!result.newKey) throw new Error("Rotation returned no new key");
|
|
311
318
|
|
|
312
|
-
// Write new key to vault
|
|
313
|
-
const {
|
|
314
|
-
|
|
319
|
+
// Write new key to vault after preserving an encrypted recovery snapshot.
|
|
320
|
+
const { result: writeResult } = await writeCredentialWithRecovery({
|
|
321
|
+
password,
|
|
322
|
+
machineHash,
|
|
323
|
+
service: serviceName,
|
|
324
|
+
value: result.newKey,
|
|
325
|
+
logFile,
|
|
326
|
+
});
|
|
315
327
|
if (writeResult.error) throw new Error(`Write failed: ${writeResult.error}`);
|
|
316
328
|
|
|
317
329
|
// Update expiry in state
|
|
@@ -412,6 +424,26 @@ const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
|
|
|
412
424
|
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
413
425
|
const LIVE_PORT = 52437;
|
|
414
426
|
const STAGED_PORT = 52438;
|
|
427
|
+
const WRITE_TOKEN_BYTES = 32;
|
|
428
|
+
const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
429
|
+
|
|
430
|
+
function makeWriteToken() {
|
|
431
|
+
return {
|
|
432
|
+
token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
|
|
433
|
+
expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function validateWriteToken(req, writeSession) {
|
|
438
|
+
if (!writeSession?.token || Date.now() > writeSession.expiresAt) return false;
|
|
439
|
+
const header = req.headers["x-clauth-write-token"] || req.headers.authorization;
|
|
440
|
+
const token = Array.isArray(header) ? header[0] : header;
|
|
441
|
+
if (!token) return false;
|
|
442
|
+
const supplied = String(token).startsWith("Bearer ") ? String(token).slice(7) : String(token);
|
|
443
|
+
const a = Buffer.from(supplied);
|
|
444
|
+
const b = Buffer.from(writeSession.token);
|
|
445
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
446
|
+
}
|
|
415
447
|
|
|
416
448
|
// ── PID helpers ──────────────────────────────────────────────
|
|
417
449
|
function readPid() {
|
|
@@ -607,14 +639,14 @@ function dashboardHtml(port, whitelist, isStaged = false) {
|
|
|
607
639
|
.project-tab{background:none;border:none;border-bottom:2px solid transparent;color:#64748b;padding:8px 16px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap;transition:all .15s}
|
|
608
640
|
.project-tab:hover{color:#94a3b8;background:rgba(59,130,246,.05)}
|
|
609
641
|
.project-tab.active{color:#60a5fa;border-bottom-color:#3b82f6;background:rgba(59,130,246,.08)}
|
|
610
|
-
.project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
|
|
611
|
-
.project-tab.active .tab-count{color:#3b82f6}
|
|
612
|
-
.service-search{display:flex;align-items:center;gap:10px;margin:-.35rem 0 1rem;background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:9px 12px}
|
|
613
|
-
.service-search-label{font-size:.76rem;color:#64748b;font-weight:600;letter-spacing:.02em;text-transform:uppercase;white-space:nowrap}
|
|
614
|
-
.service-search-input{flex:1;min-width:180px;background:#0a0f1a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:8px 11px;outline:none;transition:border-color .15s}
|
|
615
|
-
.service-search-input:focus{border-color:#3b82f6}
|
|
616
|
-
.service-search-count{font-size:.78rem;color:#64748b;white-space:nowrap}
|
|
617
|
-
.project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
|
|
642
|
+
.project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
|
|
643
|
+
.project-tab.active .tab-count{color:#3b82f6}
|
|
644
|
+
.service-search{display:flex;align-items:center;gap:10px;margin:-.35rem 0 1rem;background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:9px 12px}
|
|
645
|
+
.service-search-label{font-size:.76rem;color:#64748b;font-weight:600;letter-spacing:.02em;text-transform:uppercase;white-space:nowrap}
|
|
646
|
+
.service-search-input{flex:1;min-width:180px;background:#0a0f1a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:8px 11px;outline:none;transition:border-color .15s}
|
|
647
|
+
.service-search-input:focus{border-color:#3b82f6}
|
|
648
|
+
.service-search-count{font-size:.78rem;color:#64748b;white-space:nowrap}
|
|
649
|
+
.project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
|
|
618
650
|
.project-edit.open{display:flex;gap:6px;align-items:center}
|
|
619
651
|
.project-edit input{background:#1e293b;border:1px solid #334155;border-radius:4px;color:#e2e8f0;font-size:.78rem;padding:4px 8px;outline:none;flex:1;font-family:'Courier New',monospace;transition:border-color .15s}
|
|
620
652
|
.project-edit input:focus{border-color:#3b82f6}
|
|
@@ -868,13 +900,13 @@ function dashboardHtml(port, whitelist, isStaged = false) {
|
|
|
868
900
|
<div class="wizard-foot" id="wizard-foot"></div>
|
|
869
901
|
</div>
|
|
870
902
|
|
|
871
|
-
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
872
|
-
<div id="service-search" class="service-search">
|
|
873
|
-
<span class="service-search-label">Search</span>
|
|
874
|
-
<input id="service-search-input" class="service-search-input" type="search" placeholder="service name or display name" autocomplete="off" spellcheck="false" oninput="setServiceSearch(this.value)">
|
|
875
|
-
<span id="service-search-count" class="service-search-count"></span>
|
|
876
|
-
</div>
|
|
877
|
-
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
903
|
+
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
904
|
+
<div id="service-search" class="service-search">
|
|
905
|
+
<span class="service-search-label">Search</span>
|
|
906
|
+
<input id="service-search-input" class="service-search-input" type="search" placeholder="service name or display name" autocomplete="off" spellcheck="false" oninput="setServiceSearch(this.value)">
|
|
907
|
+
<span id="service-search-count" class="service-search-count"></span>
|
|
908
|
+
</div>
|
|
909
|
+
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
878
910
|
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
879
911
|
</div>
|
|
880
912
|
|
|
@@ -968,6 +1000,13 @@ function renderSetPanel(name) {
|
|
|
968
1000
|
}
|
|
969
1001
|
|
|
970
1002
|
// ── Boot: check lock state ──────────────────
|
|
1003
|
+
let writeToken = null;
|
|
1004
|
+
|
|
1005
|
+
function writeHeaders(extra) {
|
|
1006
|
+
if (!writeToken) throw new Error("Write access requires password unlock in this browser session.");
|
|
1007
|
+
return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
971
1010
|
async function boot() {
|
|
972
1011
|
try {
|
|
973
1012
|
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
@@ -1090,6 +1129,7 @@ async function unlock() {
|
|
|
1090
1129
|
throw new Error(r.error + rem);
|
|
1091
1130
|
}
|
|
1092
1131
|
|
|
1132
|
+
writeToken = r.write_token || null;
|
|
1093
1133
|
input.value = "";
|
|
1094
1134
|
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
1095
1135
|
showMain(ping);
|
|
@@ -1107,6 +1147,7 @@ async function unlock() {
|
|
|
1107
1147
|
// ── Lock ────────────────────────────────────
|
|
1108
1148
|
async function lockVault() {
|
|
1109
1149
|
const r = await fetch(BASE + "/lock", { method: "POST" }).then(r => r.json()).catch(() => ({}));
|
|
1150
|
+
writeToken = null;
|
|
1110
1151
|
showLockScreen(r.hard_locked || false);
|
|
1111
1152
|
}
|
|
1112
1153
|
|
|
@@ -1168,25 +1209,25 @@ async function stopDaemon() {
|
|
|
1168
1209
|
}
|
|
1169
1210
|
|
|
1170
1211
|
// ── Load services ───────────────────────────
|
|
1171
|
-
let allServices = [];
|
|
1172
|
-
let activeProjectTab = "all";
|
|
1173
|
-
let serviceSearchQuery = "";
|
|
1174
|
-
|
|
1175
|
-
function serviceSort(a, b) {
|
|
1176
|
-
return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base", numeric: true });
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function matchesServiceSearch(s, query) {
|
|
1180
|
-
if (!query) return true;
|
|
1181
|
-
const q = query.toLowerCase();
|
|
1182
|
-
return String(s.name || "").toLowerCase().includes(q) ||
|
|
1183
|
-
String(s.label || "").toLowerCase().includes(q);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function setServiceSearch(value) {
|
|
1187
|
-
serviceSearchQuery = value || "";
|
|
1188
|
-
renderServiceGrid(allServices);
|
|
1189
|
-
}
|
|
1212
|
+
let allServices = [];
|
|
1213
|
+
let activeProjectTab = "all";
|
|
1214
|
+
let serviceSearchQuery = "";
|
|
1215
|
+
|
|
1216
|
+
function serviceSort(a, b) {
|
|
1217
|
+
return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base", numeric: true });
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function matchesServiceSearch(s, query) {
|
|
1221
|
+
if (!query) return true;
|
|
1222
|
+
const q = query.toLowerCase();
|
|
1223
|
+
return String(s.name || "").toLowerCase().includes(q) ||
|
|
1224
|
+
String(s.label || "").toLowerCase().includes(q);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function setServiceSearch(value) {
|
|
1228
|
+
serviceSearchQuery = value || "";
|
|
1229
|
+
renderServiceGrid(allServices);
|
|
1230
|
+
}
|
|
1190
1231
|
|
|
1191
1232
|
function renderProjectTabs(services) {
|
|
1192
1233
|
const tabsEl = document.getElementById("project-tabs");
|
|
@@ -1212,35 +1253,35 @@ function renderProjectTabs(services) {
|
|
|
1212
1253
|
).join("");
|
|
1213
1254
|
}
|
|
1214
1255
|
|
|
1215
|
-
function switchProjectTab(key) {
|
|
1216
|
-
activeProjectTab = key;
|
|
1217
|
-
renderProjectTabs(allServices);
|
|
1218
|
-
renderServiceGrid(allServices);
|
|
1219
|
-
}
|
|
1256
|
+
function switchProjectTab(key) {
|
|
1257
|
+
activeProjectTab = key;
|
|
1258
|
+
renderProjectTabs(allServices);
|
|
1259
|
+
renderServiceGrid(allServices);
|
|
1260
|
+
}
|
|
1220
1261
|
|
|
1221
1262
|
function renderServiceGrid(services) {
|
|
1222
1263
|
const grid = document.getElementById("grid");
|
|
1223
1264
|
let filtered = services;
|
|
1224
1265
|
if (activeProjectTab === "unassigned") {
|
|
1225
1266
|
filtered = services.filter(s => !s.project);
|
|
1226
|
-
} else if (activeProjectTab !== "all") {
|
|
1227
|
-
filtered = services.filter(s => s.project === activeProjectTab);
|
|
1228
|
-
}
|
|
1229
|
-
filtered = filtered
|
|
1230
|
-
.filter(s => matchesServiceSearch(s, serviceSearchQuery))
|
|
1231
|
-
.slice()
|
|
1232
|
-
.sort(serviceSort);
|
|
1233
|
-
const searchCount = document.getElementById("service-search-count");
|
|
1234
|
-
if (searchCount) {
|
|
1235
|
-
const trimmed = serviceSearchQuery.trim();
|
|
1236
|
-
searchCount.textContent = trimmed ? filtered.length + " match" + (filtered.length === 1 ? "" : "es") : filtered.length + " shown";
|
|
1237
|
-
}
|
|
1238
|
-
if (!filtered.length) {
|
|
1239
|
-
grid.innerHTML = serviceSearchQuery.trim()
|
|
1240
|
-
? '<p class="loading">No services match that search.</p>'
|
|
1241
|
-
: '<p class="loading">No services in this group.</p>';
|
|
1242
|
-
return;
|
|
1243
|
-
}
|
|
1267
|
+
} else if (activeProjectTab !== "all") {
|
|
1268
|
+
filtered = services.filter(s => s.project === activeProjectTab);
|
|
1269
|
+
}
|
|
1270
|
+
filtered = filtered
|
|
1271
|
+
.filter(s => matchesServiceSearch(s, serviceSearchQuery))
|
|
1272
|
+
.slice()
|
|
1273
|
+
.sort(serviceSort);
|
|
1274
|
+
const searchCount = document.getElementById("service-search-count");
|
|
1275
|
+
if (searchCount) {
|
|
1276
|
+
const trimmed = serviceSearchQuery.trim();
|
|
1277
|
+
searchCount.textContent = trimmed ? filtered.length + " match" + (filtered.length === 1 ? "" : "es") : filtered.length + " shown";
|
|
1278
|
+
}
|
|
1279
|
+
if (!filtered.length) {
|
|
1280
|
+
grid.innerHTML = serviceSearchQuery.trim()
|
|
1281
|
+
? '<p class="loading">No services match that search.</p>'
|
|
1282
|
+
: '<p class="loading">No services in this group.</p>';
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1244
1285
|
grid.innerHTML = filtered.map(s => \`
|
|
1245
1286
|
<div class="card">
|
|
1246
1287
|
<div style="display:flex;align-items:flex-start;justify-content:space-between">
|
|
@@ -1398,7 +1439,7 @@ async function rotateKey(name) {
|
|
|
1398
1439
|
const rotBtn = document.getElementById("rotbtn-" + name);
|
|
1399
1440
|
if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
|
|
1400
1441
|
try {
|
|
1401
|
-
const r = await fetch(BASE + "/rotate/" + name, { method: "POST" }).then(r => r.json());
|
|
1442
|
+
const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1402
1443
|
if (r.ok) {
|
|
1403
1444
|
if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
|
|
1404
1445
|
loadExpiry(); // refresh badges
|
|
@@ -1419,7 +1460,7 @@ async function setExpiry(name) {
|
|
|
1419
1460
|
try {
|
|
1420
1461
|
await fetch(BASE + "/set-expiry/" + name, {
|
|
1421
1462
|
method: "POST",
|
|
1422
|
-
headers: { "Content-Type": "application/json" },
|
|
1463
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1423
1464
|
body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
|
|
1424
1465
|
});
|
|
1425
1466
|
loadExpiry();
|
|
@@ -1487,7 +1528,7 @@ async function saveProject(name) {
|
|
|
1487
1528
|
try {
|
|
1488
1529
|
const r = await fetch(BASE + "/update-service", {
|
|
1489
1530
|
method: "POST",
|
|
1490
|
-
headers: { "Content-Type": "application/json" },
|
|
1531
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1491
1532
|
body: JSON.stringify({ service: name, project: project || "" })
|
|
1492
1533
|
}).then(r => r.json());
|
|
1493
1534
|
if (r.locked) { showLockScreen(); return; }
|
|
@@ -1532,7 +1573,7 @@ async function saveLabel(name) {
|
|
|
1532
1573
|
try {
|
|
1533
1574
|
const r = await fetch(BASE + "/update-service", {
|
|
1534
1575
|
method: "POST",
|
|
1535
|
-
headers: { "Content-Type": "application/json" },
|
|
1576
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1536
1577
|
body: JSON.stringify({ service: name, label: newLabel })
|
|
1537
1578
|
}).then(r => r.json());
|
|
1538
1579
|
if (r.locked) { showLockScreen(false); return; }
|
|
@@ -1556,7 +1597,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
|
|
|
1556
1597
|
async function deleteService(name) {
|
|
1557
1598
|
if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
|
|
1558
1599
|
try {
|
|
1559
|
-
const r = await fetch(BASE + "/delete/" + name, { method: "POST" }).then(r => r.json());
|
|
1600
|
+
const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1560
1601
|
if (r.locked) { showLockScreen(false); return; }
|
|
1561
1602
|
if (r.error) throw new Error(r.error);
|
|
1562
1603
|
loadServices();
|
|
@@ -1612,15 +1653,15 @@ async function saveKey(name) {
|
|
|
1612
1653
|
value = JSON.stringify(obj);
|
|
1613
1654
|
} else {
|
|
1614
1655
|
const input = document.getElementById("set-input-" + name);
|
|
1615
|
-
value = input ? input.value
|
|
1616
|
-
if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
|
|
1656
|
+
value = input ? input.value : "";
|
|
1657
|
+
if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
|
|
1617
1658
|
}
|
|
1618
1659
|
|
|
1619
1660
|
msg.className = "set-msg"; msg.textContent = "Saving…";
|
|
1620
1661
|
try {
|
|
1621
1662
|
const r = await fetch(BASE + "/set/" + name, {
|
|
1622
1663
|
method: "POST",
|
|
1623
|
-
headers: { "Content-Type": "application/json" },
|
|
1664
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1624
1665
|
body: JSON.stringify({ value })
|
|
1625
1666
|
}).then(r => r.json());
|
|
1626
1667
|
|
|
@@ -1658,7 +1699,7 @@ async function toggleService(name) {
|
|
|
1658
1699
|
try {
|
|
1659
1700
|
const r = await fetch(BASE + "/toggle/" + name, {
|
|
1660
1701
|
method: "POST",
|
|
1661
|
-
headers: { "Content-Type": "application/json" },
|
|
1702
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1662
1703
|
body: JSON.stringify({ enabled: newState })
|
|
1663
1704
|
}).then(r => r.json());
|
|
1664
1705
|
|
|
@@ -1763,12 +1804,13 @@ async function changePassword() {
|
|
|
1763
1804
|
try {
|
|
1764
1805
|
const r = await fetch(BASE + "/change-pw", {
|
|
1765
1806
|
method: "POST",
|
|
1766
|
-
headers: { "Content-Type": "application/json" },
|
|
1807
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1767
1808
|
body: JSON.stringify({ newPassword: newPw })
|
|
1768
1809
|
}).then(r => r.json());
|
|
1769
1810
|
|
|
1770
1811
|
if (r.locked) { showLockScreen(); return; }
|
|
1771
1812
|
if (r.error) throw new Error(r.error);
|
|
1813
|
+
writeToken = r.write_token || null;
|
|
1772
1814
|
|
|
1773
1815
|
msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
|
|
1774
1816
|
document.getElementById("chpw-new").value = "";
|
|
@@ -1838,7 +1880,7 @@ async function addService() {
|
|
|
1838
1880
|
if (project) payload.project = project;
|
|
1839
1881
|
const r = await fetch(BASE + "/add-service", {
|
|
1840
1882
|
method: "POST",
|
|
1841
|
-
headers: { "Content-Type": "application/json" },
|
|
1883
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1842
1884
|
body: JSON.stringify(payload)
|
|
1843
1885
|
}).then(r => r.json());
|
|
1844
1886
|
|
|
@@ -2058,7 +2100,7 @@ async function wizSubmitCfToken() {
|
|
|
2058
2100
|
|
|
2059
2101
|
const r = await fetch(BASE + "/tunnel/setup/cf-token", {
|
|
2060
2102
|
method: "POST",
|
|
2061
|
-
headers: { "Content-Type": "application/json" },
|
|
2103
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
2062
2104
|
body: JSON.stringify({ token }),
|
|
2063
2105
|
}).then(r => r.json()).catch(() => null);
|
|
2064
2106
|
|
|
@@ -2496,6 +2538,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2496
2538
|
const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
|
|
2497
2539
|
let authHardLocked = false;
|
|
2498
2540
|
let password = initPassword || null; // null = locked; set via POST /auth
|
|
2541
|
+
let writeSession = null; // null until explicit password auth grants write scope
|
|
2499
2542
|
const machineHash = getMachineHash();
|
|
2500
2543
|
|
|
2501
2544
|
// Rotation engine — starts after unlock
|
|
@@ -2522,6 +2565,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2522
2565
|
whitelist,
|
|
2523
2566
|
failCount,
|
|
2524
2567
|
MAX_FAILS,
|
|
2568
|
+
writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
|
|
2525
2569
|
};
|
|
2526
2570
|
}
|
|
2527
2571
|
|
|
@@ -2916,6 +2960,22 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2916
2960
|
const studioDebugHandled = await studioDebugRuntime.handle(req, res, url, CORS);
|
|
2917
2961
|
if (studioDebugHandled !== false) return;
|
|
2918
2962
|
|
|
2963
|
+
if (method === "GET" && reqPath === "/watchdog/services") {
|
|
2964
|
+
return ok(res, await getWatchdogStatuses());
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
if (method === "GET" && reqPath === "/watchdog/events") {
|
|
2968
|
+
const limit = Number(url.searchParams.get("limit") || 100);
|
|
2969
|
+
return ok(res, { events: readWatchdogEvents(limit) });
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
const restartMatch = reqPath.match(/^\/watchdog\/services\/([^/]+)\/restart$/);
|
|
2973
|
+
if (method === "POST" && restartMatch) {
|
|
2974
|
+
const result = restartWatchdogService(decodeURIComponent(restartMatch[1]));
|
|
2975
|
+
res.writeHead(result.ok ? 200 : 403, { "Content-Type": "application/json", ...CORS });
|
|
2976
|
+
return res.end(JSON.stringify(result));
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2919
2979
|
// ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
|
|
2920
2980
|
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
2921
2981
|
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
@@ -3115,12 +3175,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3115
3175
|
const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs", "/chitchat", "/codevelop"];
|
|
3116
3176
|
const isMcpPath = MCP_PATHS.includes(reqPath);
|
|
3117
3177
|
function toolsForPath(p) {
|
|
3118
|
-
|
|
3119
|
-
if (p === "/
|
|
3120
|
-
if (p === "/
|
|
3121
|
-
if (p === "/
|
|
3122
|
-
if (p === "/
|
|
3123
|
-
|
|
3178
|
+
const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
3179
|
+
if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
|
|
3180
|
+
if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
|
|
3181
|
+
if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
|
|
3182
|
+
if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
|
|
3183
|
+
if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
|
|
3184
|
+
return tools; // /mcp — all tools
|
|
3124
3185
|
}
|
|
3125
3186
|
function serverNameForPath(p) {
|
|
3126
3187
|
if (p === "/gws") return "gws";
|
|
@@ -3324,7 +3385,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3324
3385
|
if (rpcMethod === "tools/list") {
|
|
3325
3386
|
sseSend(session.res, "message", {
|
|
3326
3387
|
jsonrpc: "2.0", id,
|
|
3327
|
-
result: { tools: MCP_TOOLS }
|
|
3388
|
+
result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
|
|
3328
3389
|
});
|
|
3329
3390
|
return;
|
|
3330
3391
|
}
|
|
@@ -4121,6 +4182,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4121
4182
|
return false;
|
|
4122
4183
|
}
|
|
4123
4184
|
|
|
4185
|
+
function writeGuard(req, res) {
|
|
4186
|
+
if (lockedGuard(res)) return true;
|
|
4187
|
+
if (!validateWriteToken(req, writeSession)) {
|
|
4188
|
+
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
4189
|
+
res.end(JSON.stringify({ error: "write token required", write_locked: true }));
|
|
4190
|
+
return true;
|
|
4191
|
+
}
|
|
4192
|
+
return false;
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4124
4195
|
// GET /meta — return valid key_types from DB check constraint
|
|
4125
4196
|
if (method === "GET" && reqPath === "/meta") {
|
|
4126
4197
|
if (lockedGuard(res)) return;
|
|
@@ -4311,6 +4382,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4311
4382
|
const result = await api.test(pw, machineHash, token, timestamp);
|
|
4312
4383
|
if (result.error) throw new Error(result.error);
|
|
4313
4384
|
password = pw; // unlock — store in process memory only
|
|
4385
|
+
writeSession = makeWriteToken();
|
|
4314
4386
|
authFailCount = 0;
|
|
4315
4387
|
authHardLocked = false;
|
|
4316
4388
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
@@ -4365,7 +4437,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4365
4437
|
tunnelStatus = "starting";
|
|
4366
4438
|
startTunnel().catch(() => {});
|
|
4367
4439
|
}
|
|
4368
|
-
return ok(res, { ok: true, locked: false });
|
|
4440
|
+
return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
4369
4441
|
} catch {
|
|
4370
4442
|
authFailCount++;
|
|
4371
4443
|
const authRemaining = MAX_AUTH_FAILS - authFailCount;
|
|
@@ -4386,6 +4458,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4386
4458
|
// POST /lock — clear password from memory
|
|
4387
4459
|
if (method === "POST" && reqPath === "/lock") {
|
|
4388
4460
|
password = null;
|
|
4461
|
+
writeSession = null;
|
|
4389
4462
|
stopTunnel();
|
|
4390
4463
|
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
4391
4464
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
@@ -4395,7 +4468,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4395
4468
|
// POST /rename/:service — rename a service
|
|
4396
4469
|
const renameMatch = reqPath.match(/^\/rename\/([a-zA-Z0-9_-]+)$/);
|
|
4397
4470
|
if (method === "POST" && renameMatch) {
|
|
4398
|
-
if (
|
|
4471
|
+
if (writeGuard(req, res)) return;
|
|
4399
4472
|
const service = renameMatch[1].toLowerCase();
|
|
4400
4473
|
let body;
|
|
4401
4474
|
try { body = await readBody(req); } catch {
|
|
@@ -4420,7 +4493,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4420
4493
|
// POST /delete/:service — remove a service entirely
|
|
4421
4494
|
const deleteMatch = reqPath.match(/^\/delete\/([a-zA-Z0-9_-]+)$/);
|
|
4422
4495
|
if (method === "POST" && deleteMatch) {
|
|
4423
|
-
if (
|
|
4496
|
+
if (writeGuard(req, res)) return;
|
|
4424
4497
|
const service = deleteMatch[1].toLowerCase();
|
|
4425
4498
|
try {
|
|
4426
4499
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
@@ -4435,7 +4508,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4435
4508
|
// POST /toggle/:service — enable or disable a service
|
|
4436
4509
|
const toggleMatch = reqPath.match(/^\/toggle\/([a-zA-Z0-9_-]+)$/);
|
|
4437
4510
|
if (method === "POST" && toggleMatch) {
|
|
4438
|
-
if (
|
|
4511
|
+
if (writeGuard(req, res)) return;
|
|
4439
4512
|
const service = toggleMatch[1].toLowerCase();
|
|
4440
4513
|
|
|
4441
4514
|
let body;
|
|
@@ -4519,7 +4592,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4519
4592
|
|
|
4520
4593
|
// POST /rotate/:service — manually trigger rotation for a service
|
|
4521
4594
|
if (method === "POST" && reqPath.startsWith("/rotate/")) {
|
|
4522
|
-
if (
|
|
4595
|
+
if (writeGuard(req, res)) return;
|
|
4523
4596
|
const service = reqPath.slice("/rotate/".length);
|
|
4524
4597
|
if (!service) {
|
|
4525
4598
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
@@ -4531,7 +4604,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4531
4604
|
|
|
4532
4605
|
// POST /set-expiry/:service — set expiry date for a service
|
|
4533
4606
|
if (method === "POST" && reqPath.startsWith("/set-expiry/")) {
|
|
4534
|
-
if (
|
|
4607
|
+
if (writeGuard(req, res)) return;
|
|
4535
4608
|
const service = reqPath.slice("/set-expiry/".length);
|
|
4536
4609
|
let body;
|
|
4537
4610
|
try { body = await readBody(req); } catch {
|
|
@@ -4688,7 +4761,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4688
4761
|
|
|
4689
4762
|
// POST /tunnel/setup/cf-token
|
|
4690
4763
|
if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
|
|
4691
|
-
if (
|
|
4764
|
+
if (writeGuard(req, res)) return;
|
|
4692
4765
|
let body;
|
|
4693
4766
|
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
4694
4767
|
const { token: cfToken } = body;
|
|
@@ -4708,9 +4781,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4708
4781
|
const accountId = ad?.result?.[0]?.id;
|
|
4709
4782
|
const accountName = ad?.result?.[0]?.name;
|
|
4710
4783
|
|
|
4711
|
-
// Save token to vault using
|
|
4712
|
-
|
|
4713
|
-
await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
|
|
4784
|
+
// Save token to vault using the same guarded recovery path as /set/:service.
|
|
4785
|
+
await writeCredentialWithRecovery({ password, machineHash, service: "cloudflare", value: cfToken, logFile: LOG_FILE });
|
|
4714
4786
|
|
|
4715
4787
|
// Save accountId to clauth_config
|
|
4716
4788
|
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
@@ -4991,7 +5063,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4991
5063
|
|
|
4992
5064
|
// POST /change-pw — change master password (must be unlocked)
|
|
4993
5065
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
4994
|
-
if (
|
|
5066
|
+
if (writeGuard(req, res)) return;
|
|
4995
5067
|
|
|
4996
5068
|
let body;
|
|
4997
5069
|
try { body = await readBody(req); } catch {
|
|
@@ -5011,9 +5083,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5011
5083
|
const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
|
|
5012
5084
|
if (result.error) throw new Error(result.error);
|
|
5013
5085
|
password = newPassword; // update in-memory password to new one
|
|
5086
|
+
writeSession = makeWriteToken();
|
|
5014
5087
|
const logLine = `[${new Date().toISOString()}] Password changed\n`;
|
|
5015
5088
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
5016
|
-
return ok(res, { ok: true });
|
|
5089
|
+
return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
5017
5090
|
} catch (err) {
|
|
5018
5091
|
res.writeHead(502, { "Content-Type": "application/json", ...CORS });
|
|
5019
5092
|
return res.end(JSON.stringify({ error: err.message }));
|
|
@@ -5023,7 +5096,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5023
5096
|
// POST /set/:service — write a new key value into vault
|
|
5024
5097
|
const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
|
|
5025
5098
|
if (method === "POST" && setMatch) {
|
|
5026
|
-
if (
|
|
5099
|
+
if (writeGuard(req, res)) return;
|
|
5027
5100
|
const service = setMatch[1].toLowerCase();
|
|
5028
5101
|
|
|
5029
5102
|
if (whitelist && !whitelist.includes(service)) {
|
|
@@ -5042,10 +5115,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5042
5115
|
}
|
|
5043
5116
|
|
|
5044
5117
|
try {
|
|
5045
|
-
const {
|
|
5046
|
-
const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
|
|
5118
|
+
const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
|
|
5047
5119
|
if (result.error) return strike(res, 502, result.error);
|
|
5048
|
-
return ok(res, { ok: true, service });
|
|
5120
|
+
return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
|
|
5049
5121
|
} catch (err) {
|
|
5050
5122
|
return strike(res, 502, err.message);
|
|
5051
5123
|
}
|
|
@@ -5053,7 +5125,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5053
5125
|
|
|
5054
5126
|
// POST /generate-token — generate a cryptographically random bearer token and store it
|
|
5055
5127
|
if (method === "POST" && reqPath === "/generate-token") {
|
|
5056
|
-
if (
|
|
5128
|
+
if (writeGuard(req, res)) return;
|
|
5057
5129
|
|
|
5058
5130
|
let body;
|
|
5059
5131
|
try { body = await readBody(req); } catch {
|
|
@@ -5075,10 +5147,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5075
5147
|
try {
|
|
5076
5148
|
const randomHex = crypto.randomBytes(32).toString("hex");
|
|
5077
5149
|
const token = `${prefix}${randomHex}`;
|
|
5078
|
-
const {
|
|
5079
|
-
const result = await api.write(password, machineHash, authToken, timestamp, service, token);
|
|
5150
|
+
const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
|
|
5080
5151
|
if (result.error) return strike(res, 502, result.error);
|
|
5081
|
-
return ok(res, { token, service, stored: true });
|
|
5152
|
+
return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
|
|
5082
5153
|
} catch (err) {
|
|
5083
5154
|
return strike(res, 502, err.message);
|
|
5084
5155
|
}
|
|
@@ -5086,7 +5157,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5086
5157
|
|
|
5087
5158
|
// POST /add-service — register a new service in the vault
|
|
5088
5159
|
if (method === "POST" && reqPath === "/add-service") {
|
|
5089
|
-
if (
|
|
5160
|
+
if (writeGuard(req, res)) return;
|
|
5090
5161
|
|
|
5091
5162
|
let body;
|
|
5092
5163
|
try { body = await readBody(req); } catch {
|
|
@@ -5117,8 +5188,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5117
5188
|
}
|
|
5118
5189
|
|
|
5119
5190
|
// POST /update-service — update service metadata (project, label, description)
|
|
5120
|
-
if (method === "POST" && reqPath === "/update-service") {
|
|
5121
|
-
if (
|
|
5191
|
+
if (method === "POST" && reqPath === "/update-service") {
|
|
5192
|
+
if (writeGuard(req, res)) return;
|
|
5122
5193
|
|
|
5123
5194
|
let body;
|
|
5124
5195
|
try { body = await readBody(req); } catch {
|
|
@@ -5149,39 +5220,39 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5149
5220
|
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
5150
5221
|
} catch (err) {
|
|
5151
5222
|
return strike(res, 502, err.message);
|
|
5152
|
-
}
|
|
5153
|
-
}
|
|
5154
|
-
|
|
5155
|
-
if (method === "GET" && reqPath === "/search") {
|
|
5156
|
-
if (lockedGuard(res)) return;
|
|
5157
|
-
const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
|
|
5158
|
-
const project = (url.searchParams.get("project") || "").trim() || undefined;
|
|
5159
|
-
const includeAddresses = url.searchParams.get("addresses") !== "false";
|
|
5160
|
-
if (!query) {
|
|
5161
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5162
|
-
return res.end(JSON.stringify({ error: "q query parameter is required" }));
|
|
5163
|
-
}
|
|
5164
|
-
|
|
5165
|
-
try {
|
|
5166
|
-
const { token, timestamp } = deriveToken(password, machineHash);
|
|
5167
|
-
const result = await searchServices({
|
|
5168
|
-
password,
|
|
5169
|
-
machineHash,
|
|
5170
|
-
token,
|
|
5171
|
-
timestamp,
|
|
5172
|
-
project,
|
|
5173
|
-
query,
|
|
5174
|
-
includeAddresses,
|
|
5175
|
-
whitelist
|
|
5176
|
-
});
|
|
5177
|
-
if (result.error) return strike(res, 502, result.error);
|
|
5178
|
-
return ok(res, result);
|
|
5179
|
-
} catch (err) {
|
|
5180
|
-
return strike(res, 502, err.message);
|
|
5181
|
-
}
|
|
5182
|
-
}
|
|
5183
|
-
|
|
5184
|
-
// Unknown route — a wrong URL is not an auth failure. Log it, return 404,
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
|
|
5226
|
+
if (method === "GET" && reqPath === "/search") {
|
|
5227
|
+
if (lockedGuard(res)) return;
|
|
5228
|
+
const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
|
|
5229
|
+
const project = (url.searchParams.get("project") || "").trim() || undefined;
|
|
5230
|
+
const includeAddresses = url.searchParams.get("addresses") !== "false";
|
|
5231
|
+
if (!query) {
|
|
5232
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5233
|
+
return res.end(JSON.stringify({ error: "q query parameter is required" }));
|
|
5234
|
+
}
|
|
5235
|
+
|
|
5236
|
+
try {
|
|
5237
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
5238
|
+
const result = await searchServices({
|
|
5239
|
+
password,
|
|
5240
|
+
machineHash,
|
|
5241
|
+
token,
|
|
5242
|
+
timestamp,
|
|
5243
|
+
project,
|
|
5244
|
+
query,
|
|
5245
|
+
includeAddresses,
|
|
5246
|
+
whitelist
|
|
5247
|
+
});
|
|
5248
|
+
if (result.error) return strike(res, 502, result.error);
|
|
5249
|
+
return ok(res, result);
|
|
5250
|
+
} catch (err) {
|
|
5251
|
+
return strike(res, 502, err.message);
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
|
|
5255
|
+
// Unknown route — a wrong URL is not an auth failure. Log it, return 404,
|
|
5185
5256
|
// but do NOT increment failCount (which locks the vault at MAX_FAILS).
|
|
5186
5257
|
// Auth failures (wrong password, wrong token) still strike via /auth and /get/:service.
|
|
5187
5258
|
try {
|
|
@@ -5552,8 +5623,8 @@ async function actionForeground(opts) {
|
|
|
5552
5623
|
// Reuses the same auth model as the HTTP daemon.
|
|
5553
5624
|
// Secrets are delivered via temp files — never in the MCP response.
|
|
5554
5625
|
|
|
5555
|
-
import { createInterface } from "readline";
|
|
5556
|
-
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
5626
|
+
import { createInterface } from "readline";
|
|
5627
|
+
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
5557
5628
|
|
|
5558
5629
|
// ── Monkey dispatch — headless Claude CLI worker ─────────────────
|
|
5559
5630
|
function findClaudeBinary() {
|
|
@@ -6156,9 +6227,9 @@ function stopCodevelopSession(session_id) {
|
|
|
6156
6227
|
return { stopped: true, session_id };
|
|
6157
6228
|
}
|
|
6158
6229
|
|
|
6159
|
-
const ENV_MAP = {
|
|
6160
|
-
"github": "GITHUB_TOKEN",
|
|
6161
|
-
"supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
6230
|
+
const ENV_MAP = {
|
|
6231
|
+
"github": "GITHUB_TOKEN",
|
|
6232
|
+
"supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
6162
6233
|
"supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
|
|
6163
6234
|
"supabase-db": "SUPABASE_DB_URL",
|
|
6164
6235
|
"vercel": "VERCEL_TOKEN",
|
|
@@ -6170,116 +6241,116 @@ const ENV_MAP = {
|
|
|
6170
6241
|
"rocketreach": "ROCKETREACH_API_KEY",
|
|
6171
6242
|
"npm": "NPM_TOKEN",
|
|
6172
6243
|
"namecheap": "NAMECHEAP_API_KEY",
|
|
6173
|
-
"gmail": "GMAIL_CREDENTIALS",
|
|
6174
|
-
};
|
|
6175
|
-
|
|
6176
|
-
const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
|
|
6177
|
-
const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
|
|
6178
|
-
|
|
6179
|
-
function normalizeSearchText(value) {
|
|
6180
|
-
return String(value || "").toLowerCase();
|
|
6181
|
-
}
|
|
6182
|
-
|
|
6183
|
-
function redactUrlish(value) {
|
|
6184
|
-
const text = String(value || "").trim();
|
|
6185
|
-
if (!text) return "";
|
|
6186
|
-
try {
|
|
6187
|
-
const url = new URL(text);
|
|
6188
|
-
if (url.username) url.username = "***";
|
|
6189
|
-
if (url.password) url.password = "***";
|
|
6190
|
-
return url.toString();
|
|
6191
|
-
} catch {
|
|
6192
|
-
return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
|
|
6193
|
-
}
|
|
6194
|
-
}
|
|
6195
|
-
|
|
6196
|
-
function collectAddressHints(value, keyType) {
|
|
6197
|
-
if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
|
|
6198
|
-
const hints = new Set();
|
|
6199
|
-
|
|
6200
|
-
function add(candidate) {
|
|
6201
|
-
if (candidate === undefined || candidate === null) return;
|
|
6202
|
-
const text = redactUrlish(candidate);
|
|
6203
|
-
if (text) hints.add(text);
|
|
6204
|
-
}
|
|
6205
|
-
|
|
6206
|
-
function walk(node, fieldName = "") {
|
|
6207
|
-
if (node === undefined || node === null) return;
|
|
6208
|
-
if (typeof node === "string") {
|
|
6209
|
-
if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
|
|
6210
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
|
|
6211
|
-
return;
|
|
6212
|
-
}
|
|
6213
|
-
if (Array.isArray(node)) {
|
|
6214
|
-
for (const item of node) walk(item, fieldName);
|
|
6215
|
-
return;
|
|
6216
|
-
}
|
|
6217
|
-
if (typeof node === "object") {
|
|
6218
|
-
for (const [key, child] of Object.entries(node)) walk(child, key);
|
|
6219
|
-
}
|
|
6220
|
-
}
|
|
6221
|
-
|
|
6222
|
-
try {
|
|
6223
|
-
walk(JSON.parse(value));
|
|
6224
|
-
} catch {
|
|
6225
|
-
walk(value);
|
|
6226
|
-
}
|
|
6227
|
-
|
|
6228
|
-
return [...hints];
|
|
6229
|
-
}
|
|
6230
|
-
|
|
6231
|
-
async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
|
|
6232
|
-
const q = normalizeSearchText(query);
|
|
6233
|
-
if (!q) return { error: "query is required" };
|
|
6234
|
-
|
|
6235
|
-
const result = await api.status(password, machineHash, token, timestamp, project);
|
|
6236
|
-
if (result.error) return { error: result.error };
|
|
6237
|
-
|
|
6238
|
-
let services = result.services || [];
|
|
6239
|
-
if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
|
|
6240
|
-
|
|
6241
|
-
const matches = [];
|
|
6242
|
-
for (const s of services) {
|
|
6243
|
-
const fields = {
|
|
6244
|
-
name: s.name,
|
|
6245
|
-
label: s.label,
|
|
6246
|
-
project: s.project,
|
|
6247
|
-
type: s.key_type,
|
|
6248
|
-
description: s.description
|
|
6249
|
-
};
|
|
6250
|
-
const matched = Object.entries(fields)
|
|
6251
|
-
.filter(([, value]) => normalizeSearchText(value).includes(q))
|
|
6252
|
-
.map(([field]) => field);
|
|
6253
|
-
|
|
6254
|
-
let addressHints = [];
|
|
6255
|
-
if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
|
|
6256
|
-
const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
|
|
6257
|
-
if (!secret.error) {
|
|
6258
|
-
addressHints = collectAddressHints(secret.value, s.key_type);
|
|
6259
|
-
if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
|
|
6260
|
-
}
|
|
6261
|
-
}
|
|
6262
|
-
|
|
6263
|
-
if (matched.length) {
|
|
6264
|
-
matches.push({
|
|
6265
|
-
name: s.name,
|
|
6266
|
-
label: s.label || null,
|
|
6267
|
-
project: s.project || null,
|
|
6268
|
-
key_type: s.key_type,
|
|
6269
|
-
enabled: !!s.enabled,
|
|
6270
|
-
has_key: !!s.vault_key,
|
|
6271
|
-
description: s.description || null,
|
|
6272
|
-
matched: [...new Set(matched)],
|
|
6273
|
-
address_hints: addressHints
|
|
6274
|
-
});
|
|
6275
|
-
}
|
|
6276
|
-
}
|
|
6277
|
-
|
|
6278
|
-
return { query, count: matches.length, matches };
|
|
6279
|
-
}
|
|
6280
|
-
|
|
6281
|
-
// ── Filesystem service config — loaded from clauth vault ──
|
|
6282
|
-
let _fsMountsCache = null;
|
|
6244
|
+
"gmail": "GMAIL_CREDENTIALS",
|
|
6245
|
+
};
|
|
6246
|
+
|
|
6247
|
+
const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
|
|
6248
|
+
const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
|
|
6249
|
+
|
|
6250
|
+
function normalizeSearchText(value) {
|
|
6251
|
+
return String(value || "").toLowerCase();
|
|
6252
|
+
}
|
|
6253
|
+
|
|
6254
|
+
function redactUrlish(value) {
|
|
6255
|
+
const text = String(value || "").trim();
|
|
6256
|
+
if (!text) return "";
|
|
6257
|
+
try {
|
|
6258
|
+
const url = new URL(text);
|
|
6259
|
+
if (url.username) url.username = "***";
|
|
6260
|
+
if (url.password) url.password = "***";
|
|
6261
|
+
return url.toString();
|
|
6262
|
+
} catch {
|
|
6263
|
+
return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
|
|
6264
|
+
}
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
function collectAddressHints(value, keyType) {
|
|
6268
|
+
if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
|
|
6269
|
+
const hints = new Set();
|
|
6270
|
+
|
|
6271
|
+
function add(candidate) {
|
|
6272
|
+
if (candidate === undefined || candidate === null) return;
|
|
6273
|
+
const text = redactUrlish(candidate);
|
|
6274
|
+
if (text) hints.add(text);
|
|
6275
|
+
}
|
|
6276
|
+
|
|
6277
|
+
function walk(node, fieldName = "") {
|
|
6278
|
+
if (node === undefined || node === null) return;
|
|
6279
|
+
if (typeof node === "string") {
|
|
6280
|
+
if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
|
|
6281
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
|
|
6282
|
+
return;
|
|
6283
|
+
}
|
|
6284
|
+
if (Array.isArray(node)) {
|
|
6285
|
+
for (const item of node) walk(item, fieldName);
|
|
6286
|
+
return;
|
|
6287
|
+
}
|
|
6288
|
+
if (typeof node === "object") {
|
|
6289
|
+
for (const [key, child] of Object.entries(node)) walk(child, key);
|
|
6290
|
+
}
|
|
6291
|
+
}
|
|
6292
|
+
|
|
6293
|
+
try {
|
|
6294
|
+
walk(JSON.parse(value));
|
|
6295
|
+
} catch {
|
|
6296
|
+
walk(value);
|
|
6297
|
+
}
|
|
6298
|
+
|
|
6299
|
+
return [...hints];
|
|
6300
|
+
}
|
|
6301
|
+
|
|
6302
|
+
async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
|
|
6303
|
+
const q = normalizeSearchText(query);
|
|
6304
|
+
if (!q) return { error: "query is required" };
|
|
6305
|
+
|
|
6306
|
+
const result = await api.status(password, machineHash, token, timestamp, project);
|
|
6307
|
+
if (result.error) return { error: result.error };
|
|
6308
|
+
|
|
6309
|
+
let services = result.services || [];
|
|
6310
|
+
if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
|
|
6311
|
+
|
|
6312
|
+
const matches = [];
|
|
6313
|
+
for (const s of services) {
|
|
6314
|
+
const fields = {
|
|
6315
|
+
name: s.name,
|
|
6316
|
+
label: s.label,
|
|
6317
|
+
project: s.project,
|
|
6318
|
+
type: s.key_type,
|
|
6319
|
+
description: s.description
|
|
6320
|
+
};
|
|
6321
|
+
const matched = Object.entries(fields)
|
|
6322
|
+
.filter(([, value]) => normalizeSearchText(value).includes(q))
|
|
6323
|
+
.map(([field]) => field);
|
|
6324
|
+
|
|
6325
|
+
let addressHints = [];
|
|
6326
|
+
if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
|
|
6327
|
+
const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
|
|
6328
|
+
if (!secret.error) {
|
|
6329
|
+
addressHints = collectAddressHints(secret.value, s.key_type);
|
|
6330
|
+
if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
|
|
6331
|
+
}
|
|
6332
|
+
}
|
|
6333
|
+
|
|
6334
|
+
if (matched.length) {
|
|
6335
|
+
matches.push({
|
|
6336
|
+
name: s.name,
|
|
6337
|
+
label: s.label || null,
|
|
6338
|
+
project: s.project || null,
|
|
6339
|
+
key_type: s.key_type,
|
|
6340
|
+
enabled: !!s.enabled,
|
|
6341
|
+
has_key: !!s.vault_key,
|
|
6342
|
+
description: s.description || null,
|
|
6343
|
+
matched: [...new Set(matched)],
|
|
6344
|
+
address_hints: addressHints
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
}
|
|
6348
|
+
|
|
6349
|
+
return { query, count: matches.length, matches };
|
|
6350
|
+
}
|
|
6351
|
+
|
|
6352
|
+
// ── Filesystem service config — loaded from clauth vault ──
|
|
6353
|
+
let _fsMountsCache = null;
|
|
6283
6354
|
let _fsMountsCacheTime = 0;
|
|
6284
6355
|
const FS_CACHE_TTL = 60000; // 1 minute
|
|
6285
6356
|
|
|
@@ -6313,115 +6384,124 @@ async function getFileserverMounts(vault) {
|
|
|
6313
6384
|
}
|
|
6314
6385
|
}
|
|
6315
6386
|
|
|
6316
|
-
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6387
|
+
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6317
6388
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
6318
6389
|
if (error) return { error };
|
|
6319
|
-
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\": \"
|
|
6390
|
+
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)" };
|
|
6320
6391
|
const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
|
|
6321
6392
|
if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
|
|
6322
6393
|
if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
|
|
6323
|
-
const resolved = path.resolve(mount.path, requestedPath);
|
|
6324
|
-
const normalized = path.normalize(resolved);
|
|
6325
|
-
const relative = path.relative(path.normalize(mount.path), normalized);
|
|
6326
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
6327
|
-
return { error: `Path escapes mount: ${requestedPath}` };
|
|
6328
|
-
}
|
|
6329
|
-
return { resolved: normalized, mount };
|
|
6330
|
-
}
|
|
6331
|
-
|
|
6332
|
-
function checkAccess(mount, flag) {
|
|
6333
|
-
return mount.access.includes(flag);
|
|
6334
|
-
}
|
|
6335
|
-
|
|
6336
|
-
function sha256Hex(value) {
|
|
6337
|
-
return crypto.createHash("sha256").update(value).digest("hex");
|
|
6338
|
-
}
|
|
6339
|
-
|
|
6340
|
-
async function atomicWriteText(filePath, content) {
|
|
6341
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
6342
|
-
const tempPath = path.join(
|
|
6343
|
-
path.dirname(filePath),
|
|
6344
|
-
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
6345
|
-
);
|
|
6346
|
-
await writeFile(tempPath, content, "utf8");
|
|
6347
|
-
await rename(tempPath, filePath);
|
|
6348
|
-
}
|
|
6349
|
-
|
|
6350
|
-
async function fileInfo(filePath, requestedPath) {
|
|
6351
|
-
const s = await stat(filePath);
|
|
6352
|
-
const info = {
|
|
6353
|
-
path: requestedPath,
|
|
6354
|
-
type: s.isDirectory() ? "dir" : "file",
|
|
6355
|
-
size: s.size,
|
|
6356
|
-
modified: s.mtime.toISOString(),
|
|
6357
|
-
};
|
|
6358
|
-
if (s.isFile()) {
|
|
6359
|
-
const content = await readFile(filePath);
|
|
6360
|
-
info.sha256 = sha256Hex(content);
|
|
6361
|
-
}
|
|
6362
|
-
return info;
|
|
6363
|
-
}
|
|
6364
|
-
|
|
6365
|
-
const FS_UPLOAD_SESSIONS = new Map();
|
|
6366
|
-
const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
|
6367
|
-
const FS_MAX_CHUNKS = 500;
|
|
6368
|
-
const FS_MAX_CHUNK_BYTES = 128 * 1024;
|
|
6369
|
-
const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
|
|
6370
|
-
const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
|
|
6371
|
-
"docs/",
|
|
6372
|
-
".rdc/plans/",
|
|
6373
|
-
".rdc/guides/",
|
|
6374
|
-
".claude/context/",
|
|
6375
|
-
".claude/rules/",
|
|
6376
|
-
".rdc/relay/from-claude-ai/",
|
|
6377
|
-
];
|
|
6378
|
-
|
|
6379
|
-
function cleanupFsUploadSessions() {
|
|
6380
|
-
const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
|
|
6381
|
-
for (const [id, session] of FS_UPLOAD_SESSIONS) {
|
|
6382
|
-
if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
|
|
6383
|
-
}
|
|
6384
|
-
}
|
|
6385
|
-
|
|
6386
|
-
function runGit(cwd, args, opts = {}) {
|
|
6387
|
-
const res = spawnSync("git", args, {
|
|
6388
|
-
cwd,
|
|
6389
|
-
encoding: "utf8",
|
|
6390
|
-
windowsHide: true,
|
|
6391
|
-
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6392
|
-
});
|
|
6393
|
-
if (res.status !== 0) {
|
|
6394
|
-
const detail = (res.stderr || res.stdout || "").trim();
|
|
6395
|
-
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6396
|
-
}
|
|
6397
|
-
return (res.stdout || "").trim();
|
|
6398
|
-
}
|
|
6399
|
-
|
|
6400
|
-
function runGitRaw(cwd, args, opts = {}) {
|
|
6401
|
-
const res = spawnSync("git", args, {
|
|
6402
|
-
cwd,
|
|
6403
|
-
encoding: "buffer",
|
|
6404
|
-
windowsHide: true,
|
|
6405
|
-
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6406
|
-
});
|
|
6407
|
-
if (res.status !== 0) {
|
|
6408
|
-
const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
|
|
6409
|
-
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6410
|
-
}
|
|
6411
|
-
return res.stdout || Buffer.alloc(0);
|
|
6412
|
-
}
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
if (
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6394
|
+
const resolved = path.resolve(mount.path, requestedPath);
|
|
6395
|
+
const normalized = path.normalize(resolved);
|
|
6396
|
+
const relative = path.relative(path.normalize(mount.path), normalized);
|
|
6397
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
6398
|
+
return { error: `Path escapes mount: ${requestedPath}` };
|
|
6399
|
+
}
|
|
6400
|
+
return { resolved: normalized, mount };
|
|
6401
|
+
}
|
|
6402
|
+
|
|
6403
|
+
function checkAccess(mount, flag) {
|
|
6404
|
+
return mount.access.includes(flag);
|
|
6405
|
+
}
|
|
6406
|
+
|
|
6407
|
+
function sha256Hex(value) {
|
|
6408
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
6409
|
+
}
|
|
6410
|
+
|
|
6411
|
+
async function atomicWriteText(filePath, content) {
|
|
6412
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
6413
|
+
const tempPath = path.join(
|
|
6414
|
+
path.dirname(filePath),
|
|
6415
|
+
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
6416
|
+
);
|
|
6417
|
+
await writeFile(tempPath, content, "utf8");
|
|
6418
|
+
await rename(tempPath, filePath);
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6421
|
+
async function fileInfo(filePath, requestedPath) {
|
|
6422
|
+
const s = await stat(filePath);
|
|
6423
|
+
const info = {
|
|
6424
|
+
path: requestedPath,
|
|
6425
|
+
type: s.isDirectory() ? "dir" : "file",
|
|
6426
|
+
size: s.size,
|
|
6427
|
+
modified: s.mtime.toISOString(),
|
|
6428
|
+
};
|
|
6429
|
+
if (s.isFile()) {
|
|
6430
|
+
const content = await readFile(filePath);
|
|
6431
|
+
info.sha256 = sha256Hex(content);
|
|
6432
|
+
}
|
|
6433
|
+
return info;
|
|
6434
|
+
}
|
|
6435
|
+
|
|
6436
|
+
const FS_UPLOAD_SESSIONS = new Map();
|
|
6437
|
+
const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
|
6438
|
+
const FS_MAX_CHUNKS = 500;
|
|
6439
|
+
const FS_MAX_CHUNK_BYTES = 128 * 1024;
|
|
6440
|
+
const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
|
|
6441
|
+
const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
|
|
6442
|
+
"docs/",
|
|
6443
|
+
".rdc/plans/",
|
|
6444
|
+
".rdc/guides/",
|
|
6445
|
+
".claude/context/",
|
|
6446
|
+
".claude/rules/",
|
|
6447
|
+
".rdc/relay/from-claude-ai/",
|
|
6448
|
+
];
|
|
6449
|
+
|
|
6450
|
+
function cleanupFsUploadSessions() {
|
|
6451
|
+
const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
|
|
6452
|
+
for (const [id, session] of FS_UPLOAD_SESSIONS) {
|
|
6453
|
+
if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
|
|
6454
|
+
}
|
|
6455
|
+
}
|
|
6456
|
+
|
|
6457
|
+
function runGit(cwd, args, opts = {}) {
|
|
6458
|
+
const res = spawnSync("git", args, {
|
|
6459
|
+
cwd,
|
|
6460
|
+
encoding: "utf8",
|
|
6461
|
+
windowsHide: true,
|
|
6462
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6463
|
+
});
|
|
6464
|
+
if (res.status !== 0) {
|
|
6465
|
+
const detail = (res.stderr || res.stdout || "").trim();
|
|
6466
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6467
|
+
}
|
|
6468
|
+
return (res.stdout || "").trim();
|
|
6469
|
+
}
|
|
6470
|
+
|
|
6471
|
+
function runGitRaw(cwd, args, opts = {}) {
|
|
6472
|
+
const res = spawnSync("git", args, {
|
|
6473
|
+
cwd,
|
|
6474
|
+
encoding: "buffer",
|
|
6475
|
+
windowsHide: true,
|
|
6476
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6477
|
+
});
|
|
6478
|
+
if (res.status !== 0) {
|
|
6479
|
+
const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
|
|
6480
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6481
|
+
}
|
|
6482
|
+
return res.stdout || Buffer.alloc(0);
|
|
6483
|
+
}
|
|
6484
|
+
|
|
6485
|
+
// Read a single credential value from the vault inside an MCP handler.
|
|
6486
|
+
// (The git operations themselves live in ../lib/fs-git.js — pure + testable.)
|
|
6487
|
+
async function vaultRetrieveValue(vault, service) {
|
|
6488
|
+
if (!vault.password) return { error: "locked" };
|
|
6489
|
+
if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
|
|
6490
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
6491
|
+
return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
|
|
6492
|
+
}
|
|
6493
|
+
|
|
6494
|
+
function normalizeRepoPath(p) {
|
|
6495
|
+
if (!p || typeof p !== "string") return null;
|
|
6496
|
+
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
6497
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
6498
|
+
if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
|
|
6499
|
+
return parts.join("/");
|
|
6500
|
+
}
|
|
6501
|
+
|
|
6502
|
+
function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
|
|
6503
|
+
return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
|
|
6504
|
+
}
|
|
6425
6505
|
|
|
6426
6506
|
const MCP_TOOLS = [
|
|
6427
6507
|
{
|
|
@@ -6444,28 +6524,28 @@ const MCP_TOOLS = [
|
|
|
6444
6524
|
description: "List all services with type, enabled state, key presence, and last retrieval time",
|
|
6445
6525
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6446
6526
|
},
|
|
6447
|
-
{
|
|
6448
|
-
name: "clauth_list",
|
|
6449
|
-
description: "List registered service names",
|
|
6450
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6451
|
-
},
|
|
6452
|
-
{
|
|
6453
|
-
name: "clauth_search",
|
|
6454
|
-
description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
|
|
6455
|
-
inputSchema: {
|
|
6456
|
-
type: "object",
|
|
6457
|
-
properties: {
|
|
6458
|
-
query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
|
|
6459
|
-
project: { type: "string", description: "Optional project scope" },
|
|
6460
|
-
addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
|
|
6461
|
-
},
|
|
6462
|
-
required: ["query"],
|
|
6463
|
-
additionalProperties: false
|
|
6464
|
-
}
|
|
6465
|
-
},
|
|
6466
|
-
{
|
|
6467
|
-
name: "clauth_get",
|
|
6468
|
-
description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
|
|
6527
|
+
{
|
|
6528
|
+
name: "clauth_list",
|
|
6529
|
+
description: "List registered service names",
|
|
6530
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6531
|
+
},
|
|
6532
|
+
{
|
|
6533
|
+
name: "clauth_search",
|
|
6534
|
+
description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
|
|
6535
|
+
inputSchema: {
|
|
6536
|
+
type: "object",
|
|
6537
|
+
properties: {
|
|
6538
|
+
query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
|
|
6539
|
+
project: { type: "string", description: "Optional project scope" },
|
|
6540
|
+
addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
|
|
6541
|
+
},
|
|
6542
|
+
required: ["query"],
|
|
6543
|
+
additionalProperties: false
|
|
6544
|
+
}
|
|
6545
|
+
},
|
|
6546
|
+
{
|
|
6547
|
+
name: "clauth_get",
|
|
6548
|
+
description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
|
|
6469
6549
|
inputSchema: {
|
|
6470
6550
|
type: "object",
|
|
6471
6551
|
properties: {
|
|
@@ -6928,9 +7008,9 @@ const MCP_TOOLS = [
|
|
|
6928
7008
|
additionalProperties: false,
|
|
6929
7009
|
},
|
|
6930
7010
|
},
|
|
6931
|
-
{
|
|
6932
|
-
name: "fs_write",
|
|
6933
|
-
description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
|
|
7011
|
+
{
|
|
7012
|
+
name: "fs_write",
|
|
7013
|
+
description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
|
|
6934
7014
|
inputSchema: {
|
|
6935
7015
|
type: "object",
|
|
6936
7016
|
properties: {
|
|
@@ -6939,93 +7019,93 @@ const MCP_TOOLS = [
|
|
|
6939
7019
|
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6940
7020
|
},
|
|
6941
7021
|
required: ["path", "content"],
|
|
6942
|
-
additionalProperties: false,
|
|
6943
|
-
},
|
|
6944
|
-
},
|
|
6945
|
-
{
|
|
6946
|
-
name: "fs_stat",
|
|
6947
|
-
description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
|
|
6948
|
-
inputSchema: {
|
|
6949
|
-
type: "object",
|
|
6950
|
-
properties: {
|
|
6951
|
-
path: { type: "string", description: "Relative path within mount" },
|
|
6952
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6953
|
-
},
|
|
6954
|
-
required: ["path"],
|
|
6955
|
-
additionalProperties: false,
|
|
6956
|
-
},
|
|
6957
|
-
},
|
|
6958
|
-
{
|
|
6959
|
-
name: "fs_append",
|
|
6960
|
-
description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
|
|
6961
|
-
inputSchema: {
|
|
6962
|
-
type: "object",
|
|
6963
|
-
properties: {
|
|
6964
|
-
path: { type: "string", description: "Relative path within mount" },
|
|
6965
|
-
content: { type: "string", description: "Text to append" },
|
|
6966
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6967
|
-
expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
|
|
6968
|
-
},
|
|
6969
|
-
required: ["path", "content"],
|
|
6970
|
-
additionalProperties: false,
|
|
6971
|
-
},
|
|
6972
|
-
},
|
|
6973
|
-
{
|
|
6974
|
-
name: "fs_write_chunk",
|
|
6975
|
-
description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
|
|
6976
|
-
inputSchema: {
|
|
6977
|
-
type: "object",
|
|
6978
|
-
properties: {
|
|
6979
|
-
upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
|
|
6980
|
-
path: { type: "string", description: "Relative path within mount" },
|
|
6981
|
-
chunk_index: { type: "number", description: "Zero-based chunk index" },
|
|
6982
|
-
total_chunks: { type: "number", description: "Total chunks expected" },
|
|
6983
|
-
content: { type: "string", description: "Chunk text content" },
|
|
6984
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6985
|
-
expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
|
|
6986
|
-
},
|
|
6987
|
-
required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
|
|
6988
|
-
additionalProperties: false,
|
|
6989
|
-
},
|
|
6990
|
-
},
|
|
6991
|
-
{
|
|
6992
|
-
name: "fs_ingest_url",
|
|
6993
|
-
description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
|
|
6994
|
-
inputSchema: {
|
|
6995
|
-
type: "object",
|
|
6996
|
-
properties: {
|
|
6997
|
-
url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
|
|
6998
|
-
path: { type: "string", description: "Relative output path within mount" },
|
|
6999
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7000
|
-
max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
|
|
7001
|
-
expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
|
|
7002
|
-
},
|
|
7003
|
-
required: ["url", "path"],
|
|
7004
|
-
additionalProperties: false,
|
|
7005
|
-
},
|
|
7006
|
-
},
|
|
7007
|
-
{
|
|
7008
|
-
name: "fs_import_git_files",
|
|
7009
|
-
description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
|
|
7010
|
-
inputSchema: {
|
|
7011
|
-
type: "object",
|
|
7012
|
-
properties: {
|
|
7013
|
-
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
7014
|
-
ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
|
|
7015
|
-
paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
|
|
7016
|
-
mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
|
|
7017
|
-
commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
|
|
7018
|
-
message: { type: "string", description: "Commit subject when commit=true" },
|
|
7019
|
-
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7020
|
-
allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
|
|
7021
|
-
},
|
|
7022
|
-
required: ["ref", "paths"],
|
|
7023
|
-
additionalProperties: false,
|
|
7024
|
-
},
|
|
7025
|
-
},
|
|
7026
|
-
{
|
|
7027
|
-
name: "fs_list",
|
|
7028
|
-
description: "List directory contents with file type, size, and modification time.",
|
|
7022
|
+
additionalProperties: false,
|
|
7023
|
+
},
|
|
7024
|
+
},
|
|
7025
|
+
{
|
|
7026
|
+
name: "fs_stat",
|
|
7027
|
+
description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
|
|
7028
|
+
inputSchema: {
|
|
7029
|
+
type: "object",
|
|
7030
|
+
properties: {
|
|
7031
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
7032
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7033
|
+
},
|
|
7034
|
+
required: ["path"],
|
|
7035
|
+
additionalProperties: false,
|
|
7036
|
+
},
|
|
7037
|
+
},
|
|
7038
|
+
{
|
|
7039
|
+
name: "fs_append",
|
|
7040
|
+
description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
|
|
7041
|
+
inputSchema: {
|
|
7042
|
+
type: "object",
|
|
7043
|
+
properties: {
|
|
7044
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
7045
|
+
content: { type: "string", description: "Text to append" },
|
|
7046
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7047
|
+
expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
|
|
7048
|
+
},
|
|
7049
|
+
required: ["path", "content"],
|
|
7050
|
+
additionalProperties: false,
|
|
7051
|
+
},
|
|
7052
|
+
},
|
|
7053
|
+
{
|
|
7054
|
+
name: "fs_write_chunk",
|
|
7055
|
+
description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
|
|
7056
|
+
inputSchema: {
|
|
7057
|
+
type: "object",
|
|
7058
|
+
properties: {
|
|
7059
|
+
upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
|
|
7060
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
7061
|
+
chunk_index: { type: "number", description: "Zero-based chunk index" },
|
|
7062
|
+
total_chunks: { type: "number", description: "Total chunks expected" },
|
|
7063
|
+
content: { type: "string", description: "Chunk text content" },
|
|
7064
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7065
|
+
expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
|
|
7066
|
+
},
|
|
7067
|
+
required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
|
|
7068
|
+
additionalProperties: false,
|
|
7069
|
+
},
|
|
7070
|
+
},
|
|
7071
|
+
{
|
|
7072
|
+
name: "fs_ingest_url",
|
|
7073
|
+
description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
|
|
7074
|
+
inputSchema: {
|
|
7075
|
+
type: "object",
|
|
7076
|
+
properties: {
|
|
7077
|
+
url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
|
|
7078
|
+
path: { type: "string", description: "Relative output path within mount" },
|
|
7079
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7080
|
+
max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
|
|
7081
|
+
expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
|
|
7082
|
+
},
|
|
7083
|
+
required: ["url", "path"],
|
|
7084
|
+
additionalProperties: false,
|
|
7085
|
+
},
|
|
7086
|
+
},
|
|
7087
|
+
{
|
|
7088
|
+
name: "fs_import_git_files",
|
|
7089
|
+
description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
|
|
7090
|
+
inputSchema: {
|
|
7091
|
+
type: "object",
|
|
7092
|
+
properties: {
|
|
7093
|
+
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
7094
|
+
ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
|
|
7095
|
+
paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
|
|
7096
|
+
mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
|
|
7097
|
+
commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
|
|
7098
|
+
message: { type: "string", description: "Commit subject when commit=true" },
|
|
7099
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7100
|
+
allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
|
|
7101
|
+
},
|
|
7102
|
+
required: ["ref", "paths"],
|
|
7103
|
+
additionalProperties: false,
|
|
7104
|
+
},
|
|
7105
|
+
},
|
|
7106
|
+
{
|
|
7107
|
+
name: "fs_list",
|
|
7108
|
+
description: "List directory contents with file type, size, and modification time.",
|
|
7029
7109
|
inputSchema: {
|
|
7030
7110
|
type: "object",
|
|
7031
7111
|
properties: {
|
|
@@ -7092,13 +7172,115 @@ const MCP_TOOLS = [
|
|
|
7092
7172
|
additionalProperties: false,
|
|
7093
7173
|
},
|
|
7094
7174
|
},
|
|
7175
|
+
{
|
|
7176
|
+
name: "fs_edit",
|
|
7177
|
+
description: "Replace a unique string in a file. Like Edit in Claude Code: fails if old_string is not found or matches multiple times unless replace_all=true. Atomic write. Returns new file info including sha256.",
|
|
7178
|
+
inputSchema: {
|
|
7179
|
+
type: "object",
|
|
7180
|
+
properties: {
|
|
7181
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
7182
|
+
old_string: { type: "string", description: "Exact text to find (must be unique unless replace_all=true)" },
|
|
7183
|
+
new_string: { type: "string", description: "Replacement text (must differ from old_string)" },
|
|
7184
|
+
replace_all: { type: "boolean", description: "Replace every occurrence (default false)" },
|
|
7185
|
+
expected_sha256: { type: "string", description: "Optional current file SHA-256 guard. If provided and mismatched, edit is rejected." },
|
|
7186
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7187
|
+
},
|
|
7188
|
+
required: ["path", "old_string", "new_string"],
|
|
7189
|
+
additionalProperties: false,
|
|
7190
|
+
},
|
|
7191
|
+
},
|
|
7192
|
+
{
|
|
7193
|
+
name: "fs_move",
|
|
7194
|
+
description: "Move or rename a file or directory within a mount. Single syscall when possible; falls back to copy+delete across devices. Refuses to overwrite existing destination unless overwrite=true.",
|
|
7195
|
+
inputSchema: {
|
|
7196
|
+
type: "object",
|
|
7197
|
+
properties: {
|
|
7198
|
+
from: { type: "string", description: "Source relative path within mount" },
|
|
7199
|
+
to: { type: "string", description: "Destination relative path within mount" },
|
|
7200
|
+
overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
|
|
7201
|
+
mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
|
|
7202
|
+
},
|
|
7203
|
+
required: ["from", "to"],
|
|
7204
|
+
additionalProperties: false,
|
|
7205
|
+
},
|
|
7206
|
+
},
|
|
7207
|
+
{
|
|
7208
|
+
name: "fs_copy",
|
|
7209
|
+
description: "Copy a file or directory tree within a mount. Recursive by default. Refuses to overwrite existing destination unless overwrite=true.",
|
|
7210
|
+
inputSchema: {
|
|
7211
|
+
type: "object",
|
|
7212
|
+
properties: {
|
|
7213
|
+
from: { type: "string", description: "Source relative path within mount" },
|
|
7214
|
+
to: { type: "string", description: "Destination relative path within mount" },
|
|
7215
|
+
overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
|
|
7216
|
+
mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
|
|
7217
|
+
},
|
|
7218
|
+
required: ["from", "to"],
|
|
7219
|
+
additionalProperties: false,
|
|
7220
|
+
},
|
|
7221
|
+
},
|
|
7095
7222
|
{
|
|
7096
7223
|
name: "fs_mounts",
|
|
7097
7224
|
description: "List configured filesystem mounts (fileserver services from vault).",
|
|
7098
7225
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
7099
7226
|
},
|
|
7227
|
+
{
|
|
7228
|
+
name: "fs_repo_status",
|
|
7229
|
+
description: "Get the current git state of the mounted repo in ONE call: branch, HEAD sha + subject, whether the tree is clean or dirty (with changed paths), staged paths, how far ahead/behind the remote you are, and whether a merge or rebase is in progress. Call this first so you KNOW what you are working with before committing — you never have to guess whether your edits or a previous push landed. Requires 'g' (git) access on the mount.",
|
|
7230
|
+
inputSchema: {
|
|
7231
|
+
type: "object",
|
|
7232
|
+
properties: {
|
|
7233
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7234
|
+
},
|
|
7235
|
+
additionalProperties: false,
|
|
7236
|
+
},
|
|
7237
|
+
},
|
|
7238
|
+
{
|
|
7239
|
+
name: "fs_use_branch",
|
|
7240
|
+
description: "Safely make sure you are on a branch before committing. Switches to an existing branch, or creates a new one from the current HEAD when create=true. Your uncommitted edits are carried forward; if switching would overwrite local changes it refuses and leaves the tree untouched, telling you which files block the switch. Refuses while a merge/rebase is in progress. Requires 'g' (git) access on the mount.",
|
|
7241
|
+
inputSchema: {
|
|
7242
|
+
type: "object",
|
|
7243
|
+
properties: {
|
|
7244
|
+
branch: { type: "string", description: "Branch name to switch to (or create)" },
|
|
7245
|
+
create: { type: "boolean", description: "Create a new branch from current HEAD instead of switching to an existing one (default false)" },
|
|
7246
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7247
|
+
},
|
|
7248
|
+
required: ["branch"],
|
|
7249
|
+
additionalProperties: false,
|
|
7250
|
+
},
|
|
7251
|
+
},
|
|
7252
|
+
{
|
|
7253
|
+
name: "fs_commit",
|
|
7254
|
+
description: "Save the files you edited durably: stages changes, commits, and PUSHES by default — usually a single call is all you need after editing. Handles the messy cases for you: nothing-to-commit returns cleanly (no error); a merge/rebase in progress or a detached HEAD is refused with a clear message; if the branch is behind the remote it auto-rebases and retries the push, and on conflict it aborts the rebase (restoring your tree) and keeps the commit local so nothing is lost. Refuses to push protected branches (main/master/production) — those are promoted by a human. Returns the commit sha, the exact files committed, whether the push succeeded, and ahead/behind counts, so you never need a follow-up status call. Push auth uses the vault's github token directly (never exposed). Requires 'g' (git) access on the mount.",
|
|
7255
|
+
inputSchema: {
|
|
7256
|
+
type: "object",
|
|
7257
|
+
properties: {
|
|
7258
|
+
message: { type: "string", description: "Commit message (required)" },
|
|
7259
|
+
paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to commit. Omit to commit ALL current changes in the repo." },
|
|
7260
|
+
push: { type: "boolean", description: "Push to the remote after committing (default true)" },
|
|
7261
|
+
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
7262
|
+
author_name: { type: "string", description: "Commit author name (default: clauth-fs)" },
|
|
7263
|
+
author_email: { type: "string", description: "Commit author email (default: fs@clauth.local)" },
|
|
7264
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7265
|
+
},
|
|
7266
|
+
required: ["message"],
|
|
7267
|
+
additionalProperties: false,
|
|
7268
|
+
},
|
|
7269
|
+
},
|
|
7100
7270
|
];
|
|
7101
7271
|
|
|
7272
|
+
const MCP_WRITE_TOOL_NAMES = new Set([
|
|
7273
|
+
"clauth_enable",
|
|
7274
|
+
"clauth_disable",
|
|
7275
|
+
"clauth_set_project",
|
|
7276
|
+
"clauth_generate_token",
|
|
7277
|
+
]);
|
|
7278
|
+
|
|
7279
|
+
function filterMcpToolsForWriteMode(tools) {
|
|
7280
|
+
if (process.env.CLAUTH_MCP_WRITE === "1") return tools;
|
|
7281
|
+
return tools.filter(tool => !MCP_WRITE_TOOL_NAMES.has(tool.name));
|
|
7282
|
+
}
|
|
7283
|
+
|
|
7102
7284
|
function writeTempSecret(service, value) {
|
|
7103
7285
|
const filePath = path.join(os.tmpdir(), `.clauth-${service}`);
|
|
7104
7286
|
fs.writeFileSync(filePath, value, { mode: 0o600 });
|
|
@@ -7129,6 +7311,11 @@ function mcpError(text) {
|
|
|
7129
7311
|
const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
|
|
7130
7312
|
|
|
7131
7313
|
async function handleMcpTool(vault, name, args) {
|
|
7314
|
+
const requireMcpWrite = () => {
|
|
7315
|
+
if (vault.writeEnabled) return null;
|
|
7316
|
+
return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
|
|
7317
|
+
};
|
|
7318
|
+
|
|
7132
7319
|
switch (name) {
|
|
7133
7320
|
case "clauth_ping": {
|
|
7134
7321
|
return mcpResult(
|
|
@@ -7182,10 +7369,10 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7182
7369
|
}
|
|
7183
7370
|
}
|
|
7184
7371
|
|
|
7185
|
-
case "clauth_list": {
|
|
7186
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7187
|
-
try {
|
|
7188
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7372
|
+
case "clauth_list": {
|
|
7373
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7374
|
+
try {
|
|
7375
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7189
7376
|
const result = await api.status(vault.password, vault.machineHash, token, timestamp);
|
|
7190
7377
|
if (result.error) return mcpError(result.error);
|
|
7191
7378
|
let services = result.services || [];
|
|
@@ -7194,34 +7381,34 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7194
7381
|
}
|
|
7195
7382
|
return mcpResult(services.map(s => s.name).join(", "));
|
|
7196
7383
|
} catch (err) {
|
|
7197
|
-
return mcpError(err.message);
|
|
7198
|
-
}
|
|
7199
|
-
}
|
|
7200
|
-
|
|
7201
|
-
case "clauth_search": {
|
|
7202
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7203
|
-
try {
|
|
7204
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7205
|
-
const result = await searchServices({
|
|
7206
|
-
password: vault.password,
|
|
7207
|
-
machineHash: vault.machineHash,
|
|
7208
|
-
token,
|
|
7209
|
-
timestamp,
|
|
7210
|
-
query: args.query,
|
|
7211
|
-
project: args.project,
|
|
7212
|
-
includeAddresses: args.addresses !== false,
|
|
7213
|
-
whitelist: vault.whitelist
|
|
7214
|
-
});
|
|
7215
|
-
if (result.error) return mcpError(result.error);
|
|
7216
|
-
return mcpResult(JSON.stringify(result, null, 2));
|
|
7217
|
-
} catch (err) {
|
|
7218
|
-
return mcpError(err.message);
|
|
7219
|
-
}
|
|
7220
|
-
}
|
|
7221
|
-
|
|
7222
|
-
case "clauth_get": {
|
|
7223
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7224
|
-
const service = (args.service || "").toLowerCase();
|
|
7384
|
+
return mcpError(err.message);
|
|
7385
|
+
}
|
|
7386
|
+
}
|
|
7387
|
+
|
|
7388
|
+
case "clauth_search": {
|
|
7389
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7390
|
+
try {
|
|
7391
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7392
|
+
const result = await searchServices({
|
|
7393
|
+
password: vault.password,
|
|
7394
|
+
machineHash: vault.machineHash,
|
|
7395
|
+
token,
|
|
7396
|
+
timestamp,
|
|
7397
|
+
query: args.query,
|
|
7398
|
+
project: args.project,
|
|
7399
|
+
includeAddresses: args.addresses !== false,
|
|
7400
|
+
whitelist: vault.whitelist
|
|
7401
|
+
});
|
|
7402
|
+
if (result.error) return mcpError(result.error);
|
|
7403
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
7404
|
+
} catch (err) {
|
|
7405
|
+
return mcpError(err.message);
|
|
7406
|
+
}
|
|
7407
|
+
}
|
|
7408
|
+
|
|
7409
|
+
case "clauth_get": {
|
|
7410
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7411
|
+
const service = (args.service || "").toLowerCase();
|
|
7225
7412
|
const target = args.target || "file";
|
|
7226
7413
|
if (!service) return mcpError("service is required");
|
|
7227
7414
|
if (vault.whitelist && !vault.whitelist.includes(service)) {
|
|
@@ -7310,6 +7497,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7310
7497
|
}
|
|
7311
7498
|
|
|
7312
7499
|
case "clauth_enable": {
|
|
7500
|
+
const writeError = requireMcpWrite();
|
|
7501
|
+
if (writeError) return writeError;
|
|
7313
7502
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7314
7503
|
const service = (args.service || "").toLowerCase();
|
|
7315
7504
|
if (!service) return mcpError("service is required");
|
|
@@ -7324,6 +7513,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7324
7513
|
}
|
|
7325
7514
|
|
|
7326
7515
|
case "clauth_disable": {
|
|
7516
|
+
const writeError = requireMcpWrite();
|
|
7517
|
+
if (writeError) return writeError;
|
|
7327
7518
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7328
7519
|
const service = (args.service || "").toLowerCase();
|
|
7329
7520
|
if (!service) return mcpError("service is required");
|
|
@@ -7363,6 +7554,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7363
7554
|
}
|
|
7364
7555
|
|
|
7365
7556
|
case "clauth_set_project": {
|
|
7557
|
+
const writeError = requireMcpWrite();
|
|
7558
|
+
if (writeError) return writeError;
|
|
7366
7559
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7367
7560
|
const service = (args.service || "").toLowerCase();
|
|
7368
7561
|
const project = args.project;
|
|
@@ -7414,6 +7607,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7414
7607
|
}
|
|
7415
7608
|
|
|
7416
7609
|
case "clauth_generate_token": {
|
|
7610
|
+
const writeError = requireMcpWrite();
|
|
7611
|
+
if (writeError) return writeError;
|
|
7417
7612
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7418
7613
|
const service = (args.service || "").trim().toLowerCase();
|
|
7419
7614
|
const prefix = args.prefix || "";
|
|
@@ -7421,8 +7616,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7421
7616
|
try {
|
|
7422
7617
|
const randomHex = crypto.randomBytes(32).toString("hex");
|
|
7423
7618
|
const generatedToken = `${prefix}${randomHex}`;
|
|
7424
|
-
const {
|
|
7425
|
-
|
|
7619
|
+
const { result } = await writeCredentialWithRecovery({
|
|
7620
|
+
password: vault.password,
|
|
7621
|
+
machineHash: vault.machineHash,
|
|
7622
|
+
service,
|
|
7623
|
+
value: generatedToken,
|
|
7624
|
+
logFile: LOG_FILE,
|
|
7625
|
+
normalize: false,
|
|
7626
|
+
});
|
|
7426
7627
|
if (result.error) return mcpError(result.error);
|
|
7427
7628
|
return mcpResult(`Token generated and stored under "${service}": ${generatedToken}`);
|
|
7428
7629
|
} catch (err) {
|
|
@@ -7515,250 +7716,250 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7515
7716
|
}
|
|
7516
7717
|
}
|
|
7517
7718
|
|
|
7518
|
-
case "fs_write": {
|
|
7519
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7520
|
-
if (r.error) return mcpError(r.error);
|
|
7521
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7522
|
-
try {
|
|
7523
|
-
await atomicWriteText(r.resolved, args.content);
|
|
7524
|
-
return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
|
|
7525
|
-
} catch (err) {
|
|
7526
|
-
return mcpError(`Write failed: ${err.message}`);
|
|
7527
|
-
}
|
|
7528
|
-
}
|
|
7529
|
-
|
|
7530
|
-
case "fs_stat": {
|
|
7531
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7532
|
-
if (r.error) return mcpError(r.error);
|
|
7533
|
-
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
7534
|
-
try {
|
|
7535
|
-
return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
|
|
7536
|
-
} catch (err) {
|
|
7537
|
-
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
7538
|
-
return mcpError(`Stat failed: ${err.message}`);
|
|
7539
|
-
}
|
|
7540
|
-
}
|
|
7541
|
-
|
|
7542
|
-
case "fs_append": {
|
|
7543
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7544
|
-
if (r.error) return mcpError(r.error);
|
|
7545
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7546
|
-
try {
|
|
7547
|
-
try {
|
|
7548
|
-
const current = await readFile(r.resolved);
|
|
7549
|
-
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
7550
|
-
return mcpError("Append rejected: current file hash does not match expected_sha256");
|
|
7551
|
-
}
|
|
7552
|
-
} catch (err) {
|
|
7553
|
-
if (err.code !== "ENOENT") throw err;
|
|
7554
|
-
if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
|
|
7555
|
-
}
|
|
7556
|
-
await mkdir(path.dirname(r.resolved), { recursive: true });
|
|
7557
|
-
await appendFile(r.resolved, args.content, "utf8");
|
|
7558
|
-
const info = await fileInfo(r.resolved, args.path);
|
|
7559
|
-
return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
|
|
7560
|
-
} catch (err) {
|
|
7561
|
-
return mcpError(`Append failed: ${err.message}`);
|
|
7562
|
-
}
|
|
7563
|
-
}
|
|
7564
|
-
|
|
7565
|
-
case "fs_write_chunk": {
|
|
7566
|
-
cleanupFsUploadSessions();
|
|
7567
|
-
const { upload_id, chunk_index, total_chunks, content } = args;
|
|
7568
|
-
const index = Number(chunk_index);
|
|
7569
|
-
const total = Number(total_chunks);
|
|
7570
|
-
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
7571
|
-
return mcpError("Invalid chunk_index/total_chunks");
|
|
7572
|
-
}
|
|
7573
|
-
if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
|
|
7574
|
-
if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
|
|
7575
|
-
return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
|
|
7576
|
-
}
|
|
7577
|
-
|
|
7578
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7579
|
-
if (r.error) return mcpError(r.error);
|
|
7580
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7581
|
-
|
|
7582
|
-
const key = `${r.mount.name}:${args.path}:${upload_id}`;
|
|
7583
|
-
let session = FS_UPLOAD_SESSIONS.get(key);
|
|
7584
|
-
if (!session) {
|
|
7585
|
-
session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
|
|
7586
|
-
FS_UPLOAD_SESSIONS.set(key, session);
|
|
7587
|
-
}
|
|
7588
|
-
if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
|
|
7589
|
-
return mcpError("Upload id collision: path or total_chunks differs from existing session");
|
|
7590
|
-
}
|
|
7591
|
-
if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
|
|
7592
|
-
return mcpError("Upload id collision: expected_sha256 differs from existing session");
|
|
7593
|
-
}
|
|
7594
|
-
|
|
7595
|
-
session.chunks.set(index, content);
|
|
7596
|
-
session.updatedAt = Date.now();
|
|
7597
|
-
|
|
7598
|
-
if (session.chunks.size < total) {
|
|
7599
|
-
return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
|
|
7600
|
-
}
|
|
7601
|
-
|
|
7602
|
-
const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
|
|
7603
|
-
const actualSha = sha256Hex(assembled);
|
|
7604
|
-
if (session.expectedSha256 && actualSha !== session.expectedSha256) {
|
|
7605
|
-
FS_UPLOAD_SESSIONS.delete(key);
|
|
7606
|
-
return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
|
|
7607
|
-
}
|
|
7608
|
-
|
|
7609
|
-
try {
|
|
7610
|
-
await atomicWriteText(r.resolved, assembled);
|
|
7611
|
-
FS_UPLOAD_SESSIONS.delete(key);
|
|
7612
|
-
return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
|
|
7613
|
-
} catch (err) {
|
|
7614
|
-
return mcpError(`Chunked write failed: ${err.message}`);
|
|
7615
|
-
}
|
|
7616
|
-
}
|
|
7617
|
-
|
|
7618
|
-
case "fs_ingest_url": {
|
|
7619
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7620
|
-
if (r.error) return mcpError(r.error);
|
|
7621
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7622
|
-
|
|
7623
|
-
let url;
|
|
7624
|
-
try {
|
|
7625
|
-
url = new URL(args.url);
|
|
7626
|
-
} catch {
|
|
7627
|
-
return mcpError("Invalid URL");
|
|
7628
|
-
}
|
|
7629
|
-
if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
|
|
7630
|
-
|
|
7631
|
-
const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
|
|
7632
|
-
try {
|
|
7633
|
-
const response = await fetch(url, { redirect: "follow" });
|
|
7634
|
-
if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
|
|
7635
|
-
const length = Number(response.headers.get("content-length") || 0);
|
|
7636
|
-
if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
|
|
7637
|
-
|
|
7638
|
-
const reader = response.body?.getReader();
|
|
7639
|
-
if (!reader) return mcpError("Fetch failed: response body is not readable");
|
|
7640
|
-
|
|
7641
|
-
let received = 0;
|
|
7642
|
-
const chunks = [];
|
|
7643
|
-
while (true) {
|
|
7644
|
-
const { done, value } = await reader.read();
|
|
7645
|
-
if (done) break;
|
|
7646
|
-
received += value.byteLength;
|
|
7647
|
-
if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
|
|
7648
|
-
chunks.push(Buffer.from(value));
|
|
7649
|
-
}
|
|
7650
|
-
|
|
7651
|
-
const content = Buffer.concat(chunks).toString("utf8");
|
|
7652
|
-
const actualSha = sha256Hex(content);
|
|
7653
|
-
if (args.expected_sha256 && actualSha !== args.expected_sha256) {
|
|
7654
|
-
return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
|
|
7655
|
-
}
|
|
7656
|
-
await atomicWriteText(r.resolved, content);
|
|
7657
|
-
return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
|
|
7658
|
-
} catch (err) {
|
|
7659
|
-
return mcpError(`Ingest failed: ${err.message}`);
|
|
7660
|
-
}
|
|
7661
|
-
}
|
|
7662
|
-
|
|
7663
|
-
case "fs_import_git_files": {
|
|
7664
|
-
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
7665
|
-
if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
|
|
7666
|
-
|
|
7667
|
-
const r = await resolveInMount(".", args.mount, vault);
|
|
7668
|
-
if (r.error) return mcpError(r.error);
|
|
7669
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7670
|
-
|
|
7671
|
-
const repoRoot = r.resolved;
|
|
7672
|
-
const remote = args.remote || "origin";
|
|
7673
|
-
const mode = args.mode || "new_only";
|
|
7674
|
-
const doCommit = args.commit === true;
|
|
7675
|
-
const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
|
|
7676
|
-
? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
|
|
7677
|
-
: FS_GIT_IMPORT_ALLOWED_PREFIXES;
|
|
7678
|
-
|
|
7679
|
-
try {
|
|
7680
|
-
const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
7681
|
-
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
7682
|
-
return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
|
|
7683
|
-
}
|
|
7684
|
-
|
|
7685
|
-
const normalizedPaths = [];
|
|
7686
|
-
for (const rawPath of args.paths) {
|
|
7687
|
-
const normalized = normalizeRepoPath(rawPath);
|
|
7688
|
-
if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
|
|
7689
|
-
if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
|
|
7690
|
-
normalizedPaths.push(normalized);
|
|
7691
|
-
}
|
|
7692
|
-
|
|
7693
|
-
if (mode === "new_only") {
|
|
7694
|
-
for (const rel of normalizedPaths) {
|
|
7695
|
-
const localPath = path.join(repoRoot, rel);
|
|
7696
|
-
try {
|
|
7697
|
-
await stat(localPath);
|
|
7698
|
-
return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
|
|
7699
|
-
} catch (err) {
|
|
7700
|
-
if (err.code !== "ENOENT") throw err;
|
|
7701
|
-
}
|
|
7702
|
-
}
|
|
7703
|
-
}
|
|
7704
|
-
|
|
7705
|
-
if (doCommit) {
|
|
7706
|
-
const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
7707
|
-
if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
|
|
7708
|
-
if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
|
|
7709
|
-
}
|
|
7710
|
-
|
|
7711
|
-
runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
|
|
7712
|
-
const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
|
|
7713
|
-
|
|
7714
|
-
for (const rel of normalizedPaths) {
|
|
7715
|
-
runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
|
|
7716
|
-
}
|
|
7717
|
-
|
|
7718
|
-
runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
|
|
7719
|
-
|
|
7720
|
-
const imported = [];
|
|
7721
|
-
for (const rel of normalizedPaths) {
|
|
7722
|
-
const localPath = path.join(repoRoot, rel);
|
|
7723
|
-
const info = await fileInfo(localPath, rel);
|
|
7724
|
-
const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
|
|
7725
|
-
const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
|
|
7726
|
-
imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
|
|
7727
|
-
}
|
|
7728
|
-
|
|
7729
|
-
let localCommit = null;
|
|
7730
|
-
if (doCommit) {
|
|
7731
|
-
runGit(repoRoot, ["add", "--", ...normalizedPaths]);
|
|
7732
|
-
const body = [
|
|
7733
|
-
args.message.trim(),
|
|
7734
|
-
"",
|
|
7735
|
-
"Imported from Claude.ai GitHub upload.",
|
|
7736
|
-
"",
|
|
7737
|
-
`Source remote: ${remote}`,
|
|
7738
|
-
`Source ref: ${args.ref}`,
|
|
7739
|
-
`Source commit: ${sourceCommit}`,
|
|
7740
|
-
"",
|
|
7741
|
-
"Paths:",
|
|
7742
|
-
...normalizedPaths.map((p) => `- ${p}`),
|
|
7743
|
-
].join("\n");
|
|
7744
|
-
runGit(repoRoot, ["commit", "-m", body]);
|
|
7745
|
-
localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
7746
|
-
}
|
|
7747
|
-
|
|
7748
|
-
return mcpResult(JSON.stringify({
|
|
7749
|
-
status: "ok",
|
|
7750
|
-
mode,
|
|
7751
|
-
committed: doCommit,
|
|
7752
|
-
source_commit: sourceCommit,
|
|
7753
|
-
local_commit: localCommit,
|
|
7754
|
-
imported,
|
|
7755
|
-
}, null, 2));
|
|
7756
|
-
} catch (err) {
|
|
7757
|
-
return mcpError(`Git import failed: ${err.message}`);
|
|
7758
|
-
}
|
|
7759
|
-
}
|
|
7760
|
-
|
|
7761
|
-
case "fs_list": {
|
|
7719
|
+
case "fs_write": {
|
|
7720
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7721
|
+
if (r.error) return mcpError(r.error);
|
|
7722
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7723
|
+
try {
|
|
7724
|
+
await atomicWriteText(r.resolved, args.content);
|
|
7725
|
+
return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
|
|
7726
|
+
} catch (err) {
|
|
7727
|
+
return mcpError(`Write failed: ${err.message}`);
|
|
7728
|
+
}
|
|
7729
|
+
}
|
|
7730
|
+
|
|
7731
|
+
case "fs_stat": {
|
|
7732
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7733
|
+
if (r.error) return mcpError(r.error);
|
|
7734
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
7735
|
+
try {
|
|
7736
|
+
return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
|
|
7737
|
+
} catch (err) {
|
|
7738
|
+
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
7739
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
7740
|
+
}
|
|
7741
|
+
}
|
|
7742
|
+
|
|
7743
|
+
case "fs_append": {
|
|
7744
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7745
|
+
if (r.error) return mcpError(r.error);
|
|
7746
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7747
|
+
try {
|
|
7748
|
+
try {
|
|
7749
|
+
const current = await readFile(r.resolved);
|
|
7750
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
7751
|
+
return mcpError("Append rejected: current file hash does not match expected_sha256");
|
|
7752
|
+
}
|
|
7753
|
+
} catch (err) {
|
|
7754
|
+
if (err.code !== "ENOENT") throw err;
|
|
7755
|
+
if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
|
|
7756
|
+
}
|
|
7757
|
+
await mkdir(path.dirname(r.resolved), { recursive: true });
|
|
7758
|
+
await appendFile(r.resolved, args.content, "utf8");
|
|
7759
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
7760
|
+
return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
|
|
7761
|
+
} catch (err) {
|
|
7762
|
+
return mcpError(`Append failed: ${err.message}`);
|
|
7763
|
+
}
|
|
7764
|
+
}
|
|
7765
|
+
|
|
7766
|
+
case "fs_write_chunk": {
|
|
7767
|
+
cleanupFsUploadSessions();
|
|
7768
|
+
const { upload_id, chunk_index, total_chunks, content } = args;
|
|
7769
|
+
const index = Number(chunk_index);
|
|
7770
|
+
const total = Number(total_chunks);
|
|
7771
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
7772
|
+
return mcpError("Invalid chunk_index/total_chunks");
|
|
7773
|
+
}
|
|
7774
|
+
if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
|
|
7775
|
+
if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
|
|
7776
|
+
return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
|
|
7777
|
+
}
|
|
7778
|
+
|
|
7779
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7780
|
+
if (r.error) return mcpError(r.error);
|
|
7781
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7782
|
+
|
|
7783
|
+
const key = `${r.mount.name}:${args.path}:${upload_id}`;
|
|
7784
|
+
let session = FS_UPLOAD_SESSIONS.get(key);
|
|
7785
|
+
if (!session) {
|
|
7786
|
+
session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
|
|
7787
|
+
FS_UPLOAD_SESSIONS.set(key, session);
|
|
7788
|
+
}
|
|
7789
|
+
if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
|
|
7790
|
+
return mcpError("Upload id collision: path or total_chunks differs from existing session");
|
|
7791
|
+
}
|
|
7792
|
+
if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
|
|
7793
|
+
return mcpError("Upload id collision: expected_sha256 differs from existing session");
|
|
7794
|
+
}
|
|
7795
|
+
|
|
7796
|
+
session.chunks.set(index, content);
|
|
7797
|
+
session.updatedAt = Date.now();
|
|
7798
|
+
|
|
7799
|
+
if (session.chunks.size < total) {
|
|
7800
|
+
return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
|
|
7801
|
+
}
|
|
7802
|
+
|
|
7803
|
+
const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
|
|
7804
|
+
const actualSha = sha256Hex(assembled);
|
|
7805
|
+
if (session.expectedSha256 && actualSha !== session.expectedSha256) {
|
|
7806
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
7807
|
+
return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
|
|
7808
|
+
}
|
|
7809
|
+
|
|
7810
|
+
try {
|
|
7811
|
+
await atomicWriteText(r.resolved, assembled);
|
|
7812
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
7813
|
+
return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
|
|
7814
|
+
} catch (err) {
|
|
7815
|
+
return mcpError(`Chunked write failed: ${err.message}`);
|
|
7816
|
+
}
|
|
7817
|
+
}
|
|
7818
|
+
|
|
7819
|
+
case "fs_ingest_url": {
|
|
7820
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7821
|
+
if (r.error) return mcpError(r.error);
|
|
7822
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7823
|
+
|
|
7824
|
+
let url;
|
|
7825
|
+
try {
|
|
7826
|
+
url = new URL(args.url);
|
|
7827
|
+
} catch {
|
|
7828
|
+
return mcpError("Invalid URL");
|
|
7829
|
+
}
|
|
7830
|
+
if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
|
|
7831
|
+
|
|
7832
|
+
const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
|
|
7833
|
+
try {
|
|
7834
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
7835
|
+
if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
|
|
7836
|
+
const length = Number(response.headers.get("content-length") || 0);
|
|
7837
|
+
if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
|
|
7838
|
+
|
|
7839
|
+
const reader = response.body?.getReader();
|
|
7840
|
+
if (!reader) return mcpError("Fetch failed: response body is not readable");
|
|
7841
|
+
|
|
7842
|
+
let received = 0;
|
|
7843
|
+
const chunks = [];
|
|
7844
|
+
while (true) {
|
|
7845
|
+
const { done, value } = await reader.read();
|
|
7846
|
+
if (done) break;
|
|
7847
|
+
received += value.byteLength;
|
|
7848
|
+
if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
|
|
7849
|
+
chunks.push(Buffer.from(value));
|
|
7850
|
+
}
|
|
7851
|
+
|
|
7852
|
+
const content = Buffer.concat(chunks).toString("utf8");
|
|
7853
|
+
const actualSha = sha256Hex(content);
|
|
7854
|
+
if (args.expected_sha256 && actualSha !== args.expected_sha256) {
|
|
7855
|
+
return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
|
|
7856
|
+
}
|
|
7857
|
+
await atomicWriteText(r.resolved, content);
|
|
7858
|
+
return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
|
|
7859
|
+
} catch (err) {
|
|
7860
|
+
return mcpError(`Ingest failed: ${err.message}`);
|
|
7861
|
+
}
|
|
7862
|
+
}
|
|
7863
|
+
|
|
7864
|
+
case "fs_import_git_files": {
|
|
7865
|
+
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
7866
|
+
if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
|
|
7867
|
+
|
|
7868
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
7869
|
+
if (r.error) return mcpError(r.error);
|
|
7870
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7871
|
+
|
|
7872
|
+
const repoRoot = r.resolved;
|
|
7873
|
+
const remote = args.remote || "origin";
|
|
7874
|
+
const mode = args.mode || "new_only";
|
|
7875
|
+
const doCommit = args.commit === true;
|
|
7876
|
+
const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
|
|
7877
|
+
? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
|
|
7878
|
+
: FS_GIT_IMPORT_ALLOWED_PREFIXES;
|
|
7879
|
+
|
|
7880
|
+
try {
|
|
7881
|
+
const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
7882
|
+
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
7883
|
+
return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
|
|
7884
|
+
}
|
|
7885
|
+
|
|
7886
|
+
const normalizedPaths = [];
|
|
7887
|
+
for (const rawPath of args.paths) {
|
|
7888
|
+
const normalized = normalizeRepoPath(rawPath);
|
|
7889
|
+
if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
|
|
7890
|
+
if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
|
|
7891
|
+
normalizedPaths.push(normalized);
|
|
7892
|
+
}
|
|
7893
|
+
|
|
7894
|
+
if (mode === "new_only") {
|
|
7895
|
+
for (const rel of normalizedPaths) {
|
|
7896
|
+
const localPath = path.join(repoRoot, rel);
|
|
7897
|
+
try {
|
|
7898
|
+
await stat(localPath);
|
|
7899
|
+
return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
|
|
7900
|
+
} catch (err) {
|
|
7901
|
+
if (err.code !== "ENOENT") throw err;
|
|
7902
|
+
}
|
|
7903
|
+
}
|
|
7904
|
+
}
|
|
7905
|
+
|
|
7906
|
+
if (doCommit) {
|
|
7907
|
+
const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
7908
|
+
if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
|
|
7909
|
+
if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
|
|
7910
|
+
}
|
|
7911
|
+
|
|
7912
|
+
runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
|
|
7913
|
+
const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
|
|
7914
|
+
|
|
7915
|
+
for (const rel of normalizedPaths) {
|
|
7916
|
+
runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
|
|
7917
|
+
}
|
|
7918
|
+
|
|
7919
|
+
runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
|
|
7920
|
+
|
|
7921
|
+
const imported = [];
|
|
7922
|
+
for (const rel of normalizedPaths) {
|
|
7923
|
+
const localPath = path.join(repoRoot, rel);
|
|
7924
|
+
const info = await fileInfo(localPath, rel);
|
|
7925
|
+
const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
|
|
7926
|
+
const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
|
|
7927
|
+
imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
|
|
7928
|
+
}
|
|
7929
|
+
|
|
7930
|
+
let localCommit = null;
|
|
7931
|
+
if (doCommit) {
|
|
7932
|
+
runGit(repoRoot, ["add", "--", ...normalizedPaths]);
|
|
7933
|
+
const body = [
|
|
7934
|
+
args.message.trim(),
|
|
7935
|
+
"",
|
|
7936
|
+
"Imported from Claude.ai GitHub upload.",
|
|
7937
|
+
"",
|
|
7938
|
+
`Source remote: ${remote}`,
|
|
7939
|
+
`Source ref: ${args.ref}`,
|
|
7940
|
+
`Source commit: ${sourceCommit}`,
|
|
7941
|
+
"",
|
|
7942
|
+
"Paths:",
|
|
7943
|
+
...normalizedPaths.map((p) => `- ${p}`),
|
|
7944
|
+
].join("\n");
|
|
7945
|
+
runGit(repoRoot, ["commit", "-m", body]);
|
|
7946
|
+
localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
7947
|
+
}
|
|
7948
|
+
|
|
7949
|
+
return mcpResult(JSON.stringify({
|
|
7950
|
+
status: "ok",
|
|
7951
|
+
mode,
|
|
7952
|
+
committed: doCommit,
|
|
7953
|
+
source_commit: sourceCommit,
|
|
7954
|
+
local_commit: localCommit,
|
|
7955
|
+
imported,
|
|
7956
|
+
}, null, 2));
|
|
7957
|
+
} catch (err) {
|
|
7958
|
+
return mcpError(`Git import failed: ${err.message}`);
|
|
7959
|
+
}
|
|
7960
|
+
}
|
|
7961
|
+
|
|
7962
|
+
case "fs_list": {
|
|
7762
7963
|
const dirPath = args.path || ".";
|
|
7763
7964
|
const r = await resolveInMount(dirPath, args.mount, vault);
|
|
7764
7965
|
if (r.error) return mcpError(r.error);
|
|
@@ -7881,13 +8082,197 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7881
8082
|
}
|
|
7882
8083
|
}
|
|
7883
8084
|
|
|
8085
|
+
case "fs_edit": {
|
|
8086
|
+
if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
|
|
8087
|
+
return mcpError("old_string and new_string must be strings");
|
|
8088
|
+
}
|
|
8089
|
+
if (args.old_string === args.new_string) {
|
|
8090
|
+
return mcpError("old_string and new_string must differ");
|
|
8091
|
+
}
|
|
8092
|
+
if (args.old_string.length === 0) {
|
|
8093
|
+
return mcpError("old_string must not be empty");
|
|
8094
|
+
}
|
|
8095
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
8096
|
+
if (r.error) return mcpError(r.error);
|
|
8097
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
8098
|
+
try {
|
|
8099
|
+
const current = await readFile(r.resolved, "utf8");
|
|
8100
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
8101
|
+
return mcpError("Edit rejected: current file hash does not match expected_sha256");
|
|
8102
|
+
}
|
|
8103
|
+
const parts = current.split(args.old_string);
|
|
8104
|
+
const occurrences = parts.length - 1;
|
|
8105
|
+
if (occurrences === 0) {
|
|
8106
|
+
return mcpError(`Edit failed: old_string not found in ${args.path}`);
|
|
8107
|
+
}
|
|
8108
|
+
if (occurrences > 1 && !args.replace_all) {
|
|
8109
|
+
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.`);
|
|
8110
|
+
}
|
|
8111
|
+
const updated = args.replace_all
|
|
8112
|
+
? parts.join(args.new_string)
|
|
8113
|
+
: current.replace(args.old_string, args.new_string);
|
|
8114
|
+
await atomicWriteText(r.resolved, updated);
|
|
8115
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
8116
|
+
return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
|
|
8117
|
+
} catch (err) {
|
|
8118
|
+
if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
|
|
8119
|
+
return mcpError(`Edit failed: ${err.message}`);
|
|
8120
|
+
}
|
|
8121
|
+
}
|
|
8122
|
+
|
|
8123
|
+
case "fs_move": {
|
|
8124
|
+
const src = await resolveInMount(args.from, args.mount, vault);
|
|
8125
|
+
if (src.error) return mcpError(src.error);
|
|
8126
|
+
const dst = await resolveInMount(args.to, args.mount, vault);
|
|
8127
|
+
if (dst.error) return mcpError(dst.error);
|
|
8128
|
+
if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
|
|
8129
|
+
return mcpError("Move requires write+delete access on this mount");
|
|
8130
|
+
}
|
|
8131
|
+
try {
|
|
8132
|
+
await stat(src.resolved);
|
|
8133
|
+
} catch (err) {
|
|
8134
|
+
if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
|
|
8135
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
8136
|
+
}
|
|
8137
|
+
let dstExists = false;
|
|
8138
|
+
try {
|
|
8139
|
+
await stat(dst.resolved);
|
|
8140
|
+
dstExists = true;
|
|
8141
|
+
} catch (err) {
|
|
8142
|
+
if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
|
|
8143
|
+
}
|
|
8144
|
+
if (dstExists && !args.overwrite) {
|
|
8145
|
+
return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
|
|
8146
|
+
}
|
|
8147
|
+
try {
|
|
8148
|
+
await mkdir(path.dirname(dst.resolved), { recursive: true });
|
|
8149
|
+
if (dstExists && args.overwrite) {
|
|
8150
|
+
const dstStat = await stat(dst.resolved);
|
|
8151
|
+
await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
|
|
8152
|
+
}
|
|
8153
|
+
try {
|
|
8154
|
+
await rename(src.resolved, dst.resolved);
|
|
8155
|
+
} catch (err) {
|
|
8156
|
+
if (err.code === "EXDEV") {
|
|
8157
|
+
await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
|
|
8158
|
+
const srcStat = await stat(src.resolved);
|
|
8159
|
+
await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
|
|
8160
|
+
} else {
|
|
8161
|
+
throw err;
|
|
8162
|
+
}
|
|
8163
|
+
}
|
|
8164
|
+
return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
|
|
8165
|
+
} catch (err) {
|
|
8166
|
+
return mcpError(`Move failed: ${err.message}`);
|
|
8167
|
+
}
|
|
8168
|
+
}
|
|
8169
|
+
|
|
8170
|
+
case "fs_copy": {
|
|
8171
|
+
const src = await resolveInMount(args.from, args.mount, vault);
|
|
8172
|
+
if (src.error) return mcpError(src.error);
|
|
8173
|
+
const dst = await resolveInMount(args.to, args.mount, vault);
|
|
8174
|
+
if (dst.error) return mcpError(dst.error);
|
|
8175
|
+
if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
|
|
8176
|
+
if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
|
|
8177
|
+
let srcStat;
|
|
8178
|
+
try {
|
|
8179
|
+
srcStat = await stat(src.resolved);
|
|
8180
|
+
} catch (err) {
|
|
8181
|
+
if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
|
|
8182
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
8183
|
+
}
|
|
8184
|
+
let dstExists = false;
|
|
8185
|
+
try {
|
|
8186
|
+
await stat(dst.resolved);
|
|
8187
|
+
dstExists = true;
|
|
8188
|
+
} catch (err) {
|
|
8189
|
+
if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
|
|
8190
|
+
}
|
|
8191
|
+
if (dstExists && !args.overwrite) {
|
|
8192
|
+
return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
|
|
8193
|
+
}
|
|
8194
|
+
try {
|
|
8195
|
+
await mkdir(path.dirname(dst.resolved), { recursive: true });
|
|
8196
|
+
await cp(src.resolved, dst.resolved, {
|
|
8197
|
+
recursive: true,
|
|
8198
|
+
errorOnExist: false,
|
|
8199
|
+
force: !!args.overwrite,
|
|
8200
|
+
});
|
|
8201
|
+
return mcpResult(JSON.stringify({
|
|
8202
|
+
status: "copied",
|
|
8203
|
+
from: args.from,
|
|
8204
|
+
to: args.to,
|
|
8205
|
+
type: srcStat.isDirectory() ? "dir" : "file",
|
|
8206
|
+
}, null, 2));
|
|
8207
|
+
} catch (err) {
|
|
8208
|
+
return mcpError(`Copy failed: ${err.message}`);
|
|
8209
|
+
}
|
|
8210
|
+
}
|
|
8211
|
+
|
|
7884
8212
|
case "fs_mounts": {
|
|
7885
8213
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
7886
8214
|
if (error) return mcpError(error);
|
|
7887
|
-
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\": \"
|
|
8215
|
+
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)");
|
|
7888
8216
|
return mcpResult(JSON.stringify(mounts, null, 2));
|
|
7889
8217
|
}
|
|
7890
8218
|
|
|
8219
|
+
case "fs_repo_status": {
|
|
8220
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8221
|
+
if (r.error) return mcpError(r.error);
|
|
8222
|
+
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.");
|
|
8223
|
+
try {
|
|
8224
|
+
const { result, error } = await fsGit.repoStatus(r.resolved);
|
|
8225
|
+
if (error) return mcpError(error);
|
|
8226
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8227
|
+
} catch (err) {
|
|
8228
|
+
return mcpError(`Status failed: ${err.message}`);
|
|
8229
|
+
}
|
|
8230
|
+
}
|
|
8231
|
+
|
|
8232
|
+
case "fs_use_branch": {
|
|
8233
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8234
|
+
if (r.error) return mcpError(r.error);
|
|
8235
|
+
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.");
|
|
8236
|
+
try {
|
|
8237
|
+
const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
|
|
8238
|
+
if (error) return mcpError(error);
|
|
8239
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8240
|
+
} catch (err) {
|
|
8241
|
+
return mcpError(`Branch switch failed: ${err.message}`);
|
|
8242
|
+
}
|
|
8243
|
+
}
|
|
8244
|
+
|
|
8245
|
+
case "fs_commit": {
|
|
8246
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8247
|
+
if (r.error) return mcpError(r.error);
|
|
8248
|
+
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.");
|
|
8249
|
+
const push = args.push !== false;
|
|
8250
|
+
// Fetch the push token from the vault up front (commit happens first, so
|
|
8251
|
+
// a missing token still preserves the local commit).
|
|
8252
|
+
let token = null, tokenError = null;
|
|
8253
|
+
if (push) {
|
|
8254
|
+
const sec = await vaultRetrieveValue(vault, "github");
|
|
8255
|
+
if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
|
|
8256
|
+
else token = sec.value;
|
|
8257
|
+
}
|
|
8258
|
+
try {
|
|
8259
|
+
const { result, error } = await fsGit.commit(r.resolved, {
|
|
8260
|
+
message: args.message,
|
|
8261
|
+
paths: args.paths,
|
|
8262
|
+
push,
|
|
8263
|
+
remote: args.remote,
|
|
8264
|
+
token,
|
|
8265
|
+
tokenError,
|
|
8266
|
+
authorName: args.author_name,
|
|
8267
|
+
authorEmail: args.author_email,
|
|
8268
|
+
});
|
|
8269
|
+
if (error) return mcpError(error);
|
|
8270
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8271
|
+
} catch (err) {
|
|
8272
|
+
return mcpError(`Commit failed: ${err.message}`);
|
|
8273
|
+
}
|
|
8274
|
+
}
|
|
8275
|
+
|
|
7891
8276
|
case "monkey_dispatch": {
|
|
7892
8277
|
const { prompt, job_id } = args;
|
|
7893
8278
|
if (!prompt) return mcpError("prompt required");
|
|
@@ -8077,6 +8462,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
8077
8462
|
whitelist,
|
|
8078
8463
|
failCount: 0,
|
|
8079
8464
|
MAX_FAILS: 10,
|
|
8465
|
+
writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
|
|
8080
8466
|
};
|
|
8081
8467
|
|
|
8082
8468
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
@@ -8115,7 +8501,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
8115
8501
|
if (msg.method === "tools/list") {
|
|
8116
8502
|
return send({
|
|
8117
8503
|
jsonrpc: "2.0", id,
|
|
8118
|
-
result: { tools: MCP_TOOLS }
|
|
8504
|
+
result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
|
|
8119
8505
|
});
|
|
8120
8506
|
}
|
|
8121
8507
|
|