@lifeaitools/clauth 1.6.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -26
- package/cli/commands/serve.js +330 -50
- package/cli/commands/watchdog.js +79 -14
- package/cli/index.js +19 -6
- package/cli/lib/fs-git.js +282 -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
|
@@ -21,6 +21,13 @@ import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp }
|
|
|
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() {
|
|
@@ -752,6 +784,7 @@ function dashboardHtml(port, whitelist, isStaged = false) {
|
|
|
752
784
|
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
753
785
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
754
786
|
<button class="btn-ccandme" id="ccandme-btn" onclick="launchCCandMe()" title="Launch CCandMe — 4-pane WezTerm: Claude + Codex + Me">⚡ CCandMe</button>
|
|
787
|
+
<button class="btn-lock" id="unlock-writes-btn" onclick="unlockWrites()" title="Enter password to enable saving changes this browser session (needed after a daemon restart when auto-unlocked via --pw)">🔓 Unlock Writes</button>
|
|
755
788
|
<button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
|
|
756
789
|
<button class="btn-stop" onclick="restartDaemon()" style="background:#1a2e1a;border:1px solid #166534;color:#86efac" title="Restart daemon — keeps vault unlocked">↺ Restart</button>
|
|
757
790
|
<button class="btn-stop" onclick="stopDaemon()" style="background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5" title="Stop daemon — password required on next start">⏹ Stop</button>
|
|
@@ -968,6 +1001,13 @@ function renderSetPanel(name) {
|
|
|
968
1001
|
}
|
|
969
1002
|
|
|
970
1003
|
// ── Boot: check lock state ──────────────────
|
|
1004
|
+
let writeToken = null;
|
|
1005
|
+
|
|
1006
|
+
function writeHeaders(extra) {
|
|
1007
|
+
if (!writeToken) throw new Error("Write access requires password unlock in this browser session.");
|
|
1008
|
+
return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
971
1011
|
async function boot() {
|
|
972
1012
|
try {
|
|
973
1013
|
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
@@ -1051,6 +1091,7 @@ function showMain(ping) {
|
|
|
1051
1091
|
}
|
|
1052
1092
|
pollTunnel();
|
|
1053
1093
|
updateBuildStatus();
|
|
1094
|
+
refreshWriteLockUi();
|
|
1054
1095
|
}
|
|
1055
1096
|
|
|
1056
1097
|
// ── Unlock ──────────────────────────────────
|
|
@@ -1090,6 +1131,7 @@ async function unlock() {
|
|
|
1090
1131
|
throw new Error(r.error + rem);
|
|
1091
1132
|
}
|
|
1092
1133
|
|
|
1134
|
+
writeToken = r.write_token || null;
|
|
1093
1135
|
input.value = "";
|
|
1094
1136
|
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
1095
1137
|
showMain(ping);
|
|
@@ -1107,9 +1149,38 @@ async function unlock() {
|
|
|
1107
1149
|
// ── Lock ────────────────────────────────────
|
|
1108
1150
|
async function lockVault() {
|
|
1109
1151
|
const r = await fetch(BASE + "/lock", { method: "POST" }).then(r => r.json()).catch(() => ({}));
|
|
1152
|
+
writeToken = null;
|
|
1110
1153
|
showLockScreen(r.hard_locked || false);
|
|
1111
1154
|
}
|
|
1112
1155
|
|
|
1156
|
+
// ── Unlock writes (re-establish write scope without locking) ──
|
|
1157
|
+
// Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
|
|
1158
|
+
// unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
|
|
1159
|
+
async function unlockWrites() {
|
|
1160
|
+
if (writeToken && !confirm("Writes are already unlocked this session. Re-unlock?")) return;
|
|
1161
|
+
const pw = prompt("Enter your vault password to enable saving changes (10-minute write session):");
|
|
1162
|
+
if (!pw) return;
|
|
1163
|
+
try {
|
|
1164
|
+
const r = await fetch(BASE + "/auth", {
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
headers: { "Content-Type": "application/json" },
|
|
1167
|
+
body: JSON.stringify({ password: pw }),
|
|
1168
|
+
}).then(r => r.json());
|
|
1169
|
+
if (r.error) { alert("Unlock failed: " + r.error); return; }
|
|
1170
|
+
writeToken = r.write_token || null;
|
|
1171
|
+
refreshWriteLockUi();
|
|
1172
|
+
alert(writeToken ? "Writes unlocked for 10 minutes." : "Unlock did not return a write token.");
|
|
1173
|
+
} catch (e) { alert("Unlock error: " + (e.message || e)); }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Reflect write-lock state on the button so it is obvious when a save will fail.
|
|
1177
|
+
function refreshWriteLockUi() {
|
|
1178
|
+
const b = document.getElementById("unlock-writes-btn");
|
|
1179
|
+
if (!b) return;
|
|
1180
|
+
if (writeToken) { b.textContent = "🔓 Writes On"; b.style.opacity = "0.6"; b.style.borderColor = ""; }
|
|
1181
|
+
else { b.textContent = "🔓 Unlock Writes"; b.style.opacity = "1"; b.style.borderColor = "#f59e0b"; }
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1113
1184
|
// ── Make Live (blue-green: promote staged instance to live port) ──
|
|
1114
1185
|
async function makeLive() {
|
|
1115
1186
|
if (!confirm("Promote this staged instance to live?\\n\\nThe current live daemon (port ${LIVE_PORT}) will be stopped and this instance will restart on port ${LIVE_PORT}.")) return;
|
|
@@ -1398,7 +1469,7 @@ async function rotateKey(name) {
|
|
|
1398
1469
|
const rotBtn = document.getElementById("rotbtn-" + name);
|
|
1399
1470
|
if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
|
|
1400
1471
|
try {
|
|
1401
|
-
const r = await fetch(BASE + "/rotate/" + name, { method: "POST" }).then(r => r.json());
|
|
1472
|
+
const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1402
1473
|
if (r.ok) {
|
|
1403
1474
|
if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
|
|
1404
1475
|
loadExpiry(); // refresh badges
|
|
@@ -1419,7 +1490,7 @@ async function setExpiry(name) {
|
|
|
1419
1490
|
try {
|
|
1420
1491
|
await fetch(BASE + "/set-expiry/" + name, {
|
|
1421
1492
|
method: "POST",
|
|
1422
|
-
headers: { "Content-Type": "application/json" },
|
|
1493
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1423
1494
|
body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
|
|
1424
1495
|
});
|
|
1425
1496
|
loadExpiry();
|
|
@@ -1487,7 +1558,7 @@ async function saveProject(name) {
|
|
|
1487
1558
|
try {
|
|
1488
1559
|
const r = await fetch(BASE + "/update-service", {
|
|
1489
1560
|
method: "POST",
|
|
1490
|
-
headers: { "Content-Type": "application/json" },
|
|
1561
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1491
1562
|
body: JSON.stringify({ service: name, project: project || "" })
|
|
1492
1563
|
}).then(r => r.json());
|
|
1493
1564
|
if (r.locked) { showLockScreen(); return; }
|
|
@@ -1532,7 +1603,7 @@ async function saveLabel(name) {
|
|
|
1532
1603
|
try {
|
|
1533
1604
|
const r = await fetch(BASE + "/update-service", {
|
|
1534
1605
|
method: "POST",
|
|
1535
|
-
headers: { "Content-Type": "application/json" },
|
|
1606
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1536
1607
|
body: JSON.stringify({ service: name, label: newLabel })
|
|
1537
1608
|
}).then(r => r.json());
|
|
1538
1609
|
if (r.locked) { showLockScreen(false); return; }
|
|
@@ -1556,7 +1627,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
|
|
|
1556
1627
|
async function deleteService(name) {
|
|
1557
1628
|
if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
|
|
1558
1629
|
try {
|
|
1559
|
-
const r = await fetch(BASE + "/delete/" + name, { method: "POST" }).then(r => r.json());
|
|
1630
|
+
const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1560
1631
|
if (r.locked) { showLockScreen(false); return; }
|
|
1561
1632
|
if (r.error) throw new Error(r.error);
|
|
1562
1633
|
loadServices();
|
|
@@ -1612,15 +1683,15 @@ async function saveKey(name) {
|
|
|
1612
1683
|
value = JSON.stringify(obj);
|
|
1613
1684
|
} else {
|
|
1614
1685
|
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; }
|
|
1686
|
+
value = input ? input.value : "";
|
|
1687
|
+
if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
|
|
1617
1688
|
}
|
|
1618
1689
|
|
|
1619
1690
|
msg.className = "set-msg"; msg.textContent = "Saving…";
|
|
1620
1691
|
try {
|
|
1621
1692
|
const r = await fetch(BASE + "/set/" + name, {
|
|
1622
1693
|
method: "POST",
|
|
1623
|
-
headers: { "Content-Type": "application/json" },
|
|
1694
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1624
1695
|
body: JSON.stringify({ value })
|
|
1625
1696
|
}).then(r => r.json());
|
|
1626
1697
|
|
|
@@ -1658,7 +1729,7 @@ async function toggleService(name) {
|
|
|
1658
1729
|
try {
|
|
1659
1730
|
const r = await fetch(BASE + "/toggle/" + name, {
|
|
1660
1731
|
method: "POST",
|
|
1661
|
-
headers: { "Content-Type": "application/json" },
|
|
1732
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1662
1733
|
body: JSON.stringify({ enabled: newState })
|
|
1663
1734
|
}).then(r => r.json());
|
|
1664
1735
|
|
|
@@ -1763,12 +1834,13 @@ async function changePassword() {
|
|
|
1763
1834
|
try {
|
|
1764
1835
|
const r = await fetch(BASE + "/change-pw", {
|
|
1765
1836
|
method: "POST",
|
|
1766
|
-
headers: { "Content-Type": "application/json" },
|
|
1837
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1767
1838
|
body: JSON.stringify({ newPassword: newPw })
|
|
1768
1839
|
}).then(r => r.json());
|
|
1769
1840
|
|
|
1770
1841
|
if (r.locked) { showLockScreen(); return; }
|
|
1771
1842
|
if (r.error) throw new Error(r.error);
|
|
1843
|
+
writeToken = r.write_token || null;
|
|
1772
1844
|
|
|
1773
1845
|
msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
|
|
1774
1846
|
document.getElementById("chpw-new").value = "";
|
|
@@ -1838,7 +1910,7 @@ async function addService() {
|
|
|
1838
1910
|
if (project) payload.project = project;
|
|
1839
1911
|
const r = await fetch(BASE + "/add-service", {
|
|
1840
1912
|
method: "POST",
|
|
1841
|
-
headers: { "Content-Type": "application/json" },
|
|
1913
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1842
1914
|
body: JSON.stringify(payload)
|
|
1843
1915
|
}).then(r => r.json());
|
|
1844
1916
|
|
|
@@ -2058,7 +2130,7 @@ async function wizSubmitCfToken() {
|
|
|
2058
2130
|
|
|
2059
2131
|
const r = await fetch(BASE + "/tunnel/setup/cf-token", {
|
|
2060
2132
|
method: "POST",
|
|
2061
|
-
headers: { "Content-Type": "application/json" },
|
|
2133
|
+
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
2062
2134
|
body: JSON.stringify({ token }),
|
|
2063
2135
|
}).then(r => r.json()).catch(() => null);
|
|
2064
2136
|
|
|
@@ -2496,6 +2568,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2496
2568
|
const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
|
|
2497
2569
|
let authHardLocked = false;
|
|
2498
2570
|
let password = initPassword || null; // null = locked; set via POST /auth
|
|
2571
|
+
let writeSession = null; // null until explicit password auth grants write scope
|
|
2499
2572
|
const machineHash = getMachineHash();
|
|
2500
2573
|
|
|
2501
2574
|
// Rotation engine — starts after unlock
|
|
@@ -2522,6 +2595,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2522
2595
|
whitelist,
|
|
2523
2596
|
failCount,
|
|
2524
2597
|
MAX_FAILS,
|
|
2598
|
+
writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
|
|
2525
2599
|
};
|
|
2526
2600
|
}
|
|
2527
2601
|
|
|
@@ -2916,6 +2990,22 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
2916
2990
|
const studioDebugHandled = await studioDebugRuntime.handle(req, res, url, CORS);
|
|
2917
2991
|
if (studioDebugHandled !== false) return;
|
|
2918
2992
|
|
|
2993
|
+
if (method === "GET" && reqPath === "/watchdog/services") {
|
|
2994
|
+
return ok(res, await getWatchdogStatuses());
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
if (method === "GET" && reqPath === "/watchdog/events") {
|
|
2998
|
+
const limit = Number(url.searchParams.get("limit") || 100);
|
|
2999
|
+
return ok(res, { events: readWatchdogEvents(limit) });
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
const restartMatch = reqPath.match(/^\/watchdog\/services\/([^/]+)\/restart$/);
|
|
3003
|
+
if (method === "POST" && restartMatch) {
|
|
3004
|
+
const result = restartWatchdogService(decodeURIComponent(restartMatch[1]));
|
|
3005
|
+
res.writeHead(result.ok ? 200 : 403, { "Content-Type": "application/json", ...CORS });
|
|
3006
|
+
return res.end(JSON.stringify(result));
|
|
3007
|
+
}
|
|
3008
|
+
|
|
2919
3009
|
// ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
|
|
2920
3010
|
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
2921
3011
|
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
@@ -3115,12 +3205,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3115
3205
|
const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs", "/chitchat", "/codevelop"];
|
|
3116
3206
|
const isMcpPath = MCP_PATHS.includes(reqPath);
|
|
3117
3207
|
function toolsForPath(p) {
|
|
3118
|
-
|
|
3119
|
-
if (p === "/
|
|
3120
|
-
if (p === "/
|
|
3121
|
-
if (p === "/
|
|
3122
|
-
if (p === "/
|
|
3123
|
-
|
|
3208
|
+
const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
3209
|
+
if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
|
|
3210
|
+
if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
|
|
3211
|
+
if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
|
|
3212
|
+
if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
|
|
3213
|
+
if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
|
|
3214
|
+
return tools; // /mcp — all tools
|
|
3124
3215
|
}
|
|
3125
3216
|
function serverNameForPath(p) {
|
|
3126
3217
|
if (p === "/gws") return "gws";
|
|
@@ -3324,7 +3415,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3324
3415
|
if (rpcMethod === "tools/list") {
|
|
3325
3416
|
sseSend(session.res, "message", {
|
|
3326
3417
|
jsonrpc: "2.0", id,
|
|
3327
|
-
result: { tools: MCP_TOOLS }
|
|
3418
|
+
result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
|
|
3328
3419
|
});
|
|
3329
3420
|
return;
|
|
3330
3421
|
}
|
|
@@ -4121,6 +4212,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4121
4212
|
return false;
|
|
4122
4213
|
}
|
|
4123
4214
|
|
|
4215
|
+
function writeGuard(req, res) {
|
|
4216
|
+
if (lockedGuard(res)) return true;
|
|
4217
|
+
if (!validateWriteToken(req, writeSession)) {
|
|
4218
|
+
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
4219
|
+
res.end(JSON.stringify({ error: "write token required", write_locked: true }));
|
|
4220
|
+
return true;
|
|
4221
|
+
}
|
|
4222
|
+
return false;
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4124
4225
|
// GET /meta — return valid key_types from DB check constraint
|
|
4125
4226
|
if (method === "GET" && reqPath === "/meta") {
|
|
4126
4227
|
if (lockedGuard(res)) return;
|
|
@@ -4311,6 +4412,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4311
4412
|
const result = await api.test(pw, machineHash, token, timestamp);
|
|
4312
4413
|
if (result.error) throw new Error(result.error);
|
|
4313
4414
|
password = pw; // unlock — store in process memory only
|
|
4415
|
+
writeSession = makeWriteToken();
|
|
4314
4416
|
authFailCount = 0;
|
|
4315
4417
|
authHardLocked = false;
|
|
4316
4418
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
@@ -4365,7 +4467,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4365
4467
|
tunnelStatus = "starting";
|
|
4366
4468
|
startTunnel().catch(() => {});
|
|
4367
4469
|
}
|
|
4368
|
-
return ok(res, { ok: true, locked: false });
|
|
4470
|
+
return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
4369
4471
|
} catch {
|
|
4370
4472
|
authFailCount++;
|
|
4371
4473
|
const authRemaining = MAX_AUTH_FAILS - authFailCount;
|
|
@@ -4386,6 +4488,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4386
4488
|
// POST /lock — clear password from memory
|
|
4387
4489
|
if (method === "POST" && reqPath === "/lock") {
|
|
4388
4490
|
password = null;
|
|
4491
|
+
writeSession = null;
|
|
4389
4492
|
stopTunnel();
|
|
4390
4493
|
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
4391
4494
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
@@ -4395,7 +4498,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4395
4498
|
// POST /rename/:service — rename a service
|
|
4396
4499
|
const renameMatch = reqPath.match(/^\/rename\/([a-zA-Z0-9_-]+)$/);
|
|
4397
4500
|
if (method === "POST" && renameMatch) {
|
|
4398
|
-
if (
|
|
4501
|
+
if (writeGuard(req, res)) return;
|
|
4399
4502
|
const service = renameMatch[1].toLowerCase();
|
|
4400
4503
|
let body;
|
|
4401
4504
|
try { body = await readBody(req); } catch {
|
|
@@ -4420,7 +4523,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4420
4523
|
// POST /delete/:service — remove a service entirely
|
|
4421
4524
|
const deleteMatch = reqPath.match(/^\/delete\/([a-zA-Z0-9_-]+)$/);
|
|
4422
4525
|
if (method === "POST" && deleteMatch) {
|
|
4423
|
-
if (
|
|
4526
|
+
if (writeGuard(req, res)) return;
|
|
4424
4527
|
const service = deleteMatch[1].toLowerCase();
|
|
4425
4528
|
try {
|
|
4426
4529
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
@@ -4435,7 +4538,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4435
4538
|
// POST /toggle/:service — enable or disable a service
|
|
4436
4539
|
const toggleMatch = reqPath.match(/^\/toggle\/([a-zA-Z0-9_-]+)$/);
|
|
4437
4540
|
if (method === "POST" && toggleMatch) {
|
|
4438
|
-
if (
|
|
4541
|
+
if (writeGuard(req, res)) return;
|
|
4439
4542
|
const service = toggleMatch[1].toLowerCase();
|
|
4440
4543
|
|
|
4441
4544
|
let body;
|
|
@@ -4519,7 +4622,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4519
4622
|
|
|
4520
4623
|
// POST /rotate/:service — manually trigger rotation for a service
|
|
4521
4624
|
if (method === "POST" && reqPath.startsWith("/rotate/")) {
|
|
4522
|
-
if (
|
|
4625
|
+
if (writeGuard(req, res)) return;
|
|
4523
4626
|
const service = reqPath.slice("/rotate/".length);
|
|
4524
4627
|
if (!service) {
|
|
4525
4628
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
@@ -4531,7 +4634,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4531
4634
|
|
|
4532
4635
|
// POST /set-expiry/:service — set expiry date for a service
|
|
4533
4636
|
if (method === "POST" && reqPath.startsWith("/set-expiry/")) {
|
|
4534
|
-
if (
|
|
4637
|
+
if (writeGuard(req, res)) return;
|
|
4535
4638
|
const service = reqPath.slice("/set-expiry/".length);
|
|
4536
4639
|
let body;
|
|
4537
4640
|
try { body = await readBody(req); } catch {
|
|
@@ -4688,7 +4791,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4688
4791
|
|
|
4689
4792
|
// POST /tunnel/setup/cf-token
|
|
4690
4793
|
if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
|
|
4691
|
-
if (
|
|
4794
|
+
if (writeGuard(req, res)) return;
|
|
4692
4795
|
let body;
|
|
4693
4796
|
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
4694
4797
|
const { token: cfToken } = body;
|
|
@@ -4708,9 +4811,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4708
4811
|
const accountId = ad?.result?.[0]?.id;
|
|
4709
4812
|
const accountName = ad?.result?.[0]?.name;
|
|
4710
4813
|
|
|
4711
|
-
// Save token to vault using
|
|
4712
|
-
|
|
4713
|
-
await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
|
|
4814
|
+
// Save token to vault using the same guarded recovery path as /set/:service.
|
|
4815
|
+
await writeCredentialWithRecovery({ password, machineHash, service: "cloudflare", value: cfToken, logFile: LOG_FILE });
|
|
4714
4816
|
|
|
4715
4817
|
// Save accountId to clauth_config
|
|
4716
4818
|
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
@@ -4991,7 +5093,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4991
5093
|
|
|
4992
5094
|
// POST /change-pw — change master password (must be unlocked)
|
|
4993
5095
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
4994
|
-
if (
|
|
5096
|
+
if (writeGuard(req, res)) return;
|
|
4995
5097
|
|
|
4996
5098
|
let body;
|
|
4997
5099
|
try { body = await readBody(req); } catch {
|
|
@@ -5011,9 +5113,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5011
5113
|
const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
|
|
5012
5114
|
if (result.error) throw new Error(result.error);
|
|
5013
5115
|
password = newPassword; // update in-memory password to new one
|
|
5116
|
+
writeSession = makeWriteToken();
|
|
5014
5117
|
const logLine = `[${new Date().toISOString()}] Password changed\n`;
|
|
5015
5118
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
5016
|
-
return ok(res, { ok: true });
|
|
5119
|
+
return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
5017
5120
|
} catch (err) {
|
|
5018
5121
|
res.writeHead(502, { "Content-Type": "application/json", ...CORS });
|
|
5019
5122
|
return res.end(JSON.stringify({ error: err.message }));
|
|
@@ -5023,7 +5126,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5023
5126
|
// POST /set/:service — write a new key value into vault
|
|
5024
5127
|
const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
|
|
5025
5128
|
if (method === "POST" && setMatch) {
|
|
5026
|
-
if (
|
|
5129
|
+
if (writeGuard(req, res)) return;
|
|
5027
5130
|
const service = setMatch[1].toLowerCase();
|
|
5028
5131
|
|
|
5029
5132
|
if (whitelist && !whitelist.includes(service)) {
|
|
@@ -5042,10 +5145,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5042
5145
|
}
|
|
5043
5146
|
|
|
5044
5147
|
try {
|
|
5045
|
-
const {
|
|
5046
|
-
const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
|
|
5148
|
+
const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
|
|
5047
5149
|
if (result.error) return strike(res, 502, result.error);
|
|
5048
|
-
return ok(res, { ok: true, service });
|
|
5150
|
+
return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
|
|
5049
5151
|
} catch (err) {
|
|
5050
5152
|
return strike(res, 502, err.message);
|
|
5051
5153
|
}
|
|
@@ -5053,7 +5155,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5053
5155
|
|
|
5054
5156
|
// POST /generate-token — generate a cryptographically random bearer token and store it
|
|
5055
5157
|
if (method === "POST" && reqPath === "/generate-token") {
|
|
5056
|
-
if (
|
|
5158
|
+
if (writeGuard(req, res)) return;
|
|
5057
5159
|
|
|
5058
5160
|
let body;
|
|
5059
5161
|
try { body = await readBody(req); } catch {
|
|
@@ -5075,10 +5177,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5075
5177
|
try {
|
|
5076
5178
|
const randomHex = crypto.randomBytes(32).toString("hex");
|
|
5077
5179
|
const token = `${prefix}${randomHex}`;
|
|
5078
|
-
const {
|
|
5079
|
-
const result = await api.write(password, machineHash, authToken, timestamp, service, token);
|
|
5180
|
+
const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
|
|
5080
5181
|
if (result.error) return strike(res, 502, result.error);
|
|
5081
|
-
return ok(res, { token, service, stored: true });
|
|
5182
|
+
return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
|
|
5082
5183
|
} catch (err) {
|
|
5083
5184
|
return strike(res, 502, err.message);
|
|
5084
5185
|
}
|
|
@@ -5086,7 +5187,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5086
5187
|
|
|
5087
5188
|
// POST /add-service — register a new service in the vault
|
|
5088
5189
|
if (method === "POST" && reqPath === "/add-service") {
|
|
5089
|
-
if (
|
|
5190
|
+
if (writeGuard(req, res)) return;
|
|
5090
5191
|
|
|
5091
5192
|
let body;
|
|
5092
5193
|
try { body = await readBody(req); } catch {
|
|
@@ -5118,7 +5219,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5118
5219
|
|
|
5119
5220
|
// POST /update-service — update service metadata (project, label, description)
|
|
5120
5221
|
if (method === "POST" && reqPath === "/update-service") {
|
|
5121
|
-
if (
|
|
5222
|
+
if (writeGuard(req, res)) return;
|
|
5122
5223
|
|
|
5123
5224
|
let body;
|
|
5124
5225
|
try { body = await readBody(req); } catch {
|
|
@@ -6316,7 +6417,7 @@ async function getFileserverMounts(vault) {
|
|
|
6316
6417
|
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6317
6418
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
6318
6419
|
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\": \"
|
|
6420
|
+
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
6421
|
const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
|
|
6321
6422
|
if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
|
|
6322
6423
|
if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
|
|
@@ -6411,6 +6512,15 @@ function runGitRaw(cwd, args, opts = {}) {
|
|
|
6411
6512
|
return res.stdout || Buffer.alloc(0);
|
|
6412
6513
|
}
|
|
6413
6514
|
|
|
6515
|
+
// Read a single credential value from the vault inside an MCP handler.
|
|
6516
|
+
// (The git operations themselves live in ../lib/fs-git.js — pure + testable.)
|
|
6517
|
+
async function vaultRetrieveValue(vault, service) {
|
|
6518
|
+
if (!vault.password) return { error: "locked" };
|
|
6519
|
+
if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
|
|
6520
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
6521
|
+
return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
|
|
6522
|
+
}
|
|
6523
|
+
|
|
6414
6524
|
function normalizeRepoPath(p) {
|
|
6415
6525
|
if (!p || typeof p !== "string") return null;
|
|
6416
6526
|
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
@@ -7144,8 +7254,79 @@ const MCP_TOOLS = [
|
|
|
7144
7254
|
description: "List configured filesystem mounts (fileserver services from vault).",
|
|
7145
7255
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
7146
7256
|
},
|
|
7257
|
+
{
|
|
7258
|
+
name: "fs_repo_status",
|
|
7259
|
+
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.",
|
|
7260
|
+
inputSchema: {
|
|
7261
|
+
type: "object",
|
|
7262
|
+
properties: {
|
|
7263
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7264
|
+
},
|
|
7265
|
+
additionalProperties: false,
|
|
7266
|
+
},
|
|
7267
|
+
},
|
|
7268
|
+
{
|
|
7269
|
+
name: "fs_use_branch",
|
|
7270
|
+
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.",
|
|
7271
|
+
inputSchema: {
|
|
7272
|
+
type: "object",
|
|
7273
|
+
properties: {
|
|
7274
|
+
branch: { type: "string", description: "Branch name to switch to (or create)" },
|
|
7275
|
+
create: { type: "boolean", description: "Create a new branch from current HEAD instead of switching to an existing one (default false)" },
|
|
7276
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7277
|
+
},
|
|
7278
|
+
required: ["branch"],
|
|
7279
|
+
additionalProperties: false,
|
|
7280
|
+
},
|
|
7281
|
+
},
|
|
7282
|
+
{
|
|
7283
|
+
name: "fs_commit",
|
|
7284
|
+
description: "Commit and push to GitHub: stages changes, commits, and PUSHES by default — usually a single call is all you need after editing. Handles the messy cases: 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. Set dry_run=true first to preview what WOULD be committed/pushed without doing it. Set expected_head to refuse committing if the base moved under you. Push auth uses the vault github token directly (never exposed). Requires 'g' (git) access on the mount.",
|
|
7285
|
+
inputSchema: {
|
|
7286
|
+
type: "object",
|
|
7287
|
+
properties: {
|
|
7288
|
+
message: { type: "string", description: "Commit message. Required for a real commit; optional when dry_run=true." },
|
|
7289
|
+
paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to commit. Omit to commit ALL current changes in the repo." },
|
|
7290
|
+
push: { type: "boolean", description: "Push to the remote after committing (default true)" },
|
|
7291
|
+
dry_run: { type: "boolean", description: "Preview only: returns the branch, the files that would be committed, and whether/where it would push — without staging, committing, or pushing. Use to confirm before a real push." },
|
|
7292
|
+
expected_head: { type: "string", description: "Optimistic-concurrency guard: the commit SHA you believe HEAD is on. If HEAD has moved, the commit is refused so you don't build on a stale base." },
|
|
7293
|
+
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
7294
|
+
author_name: { type: "string", description: "Commit author name (default: clauth-fs)" },
|
|
7295
|
+
author_email: { type: "string", description: "Commit author email (default: fs@clauth.local)" },
|
|
7296
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7297
|
+
},
|
|
7298
|
+
required: [],
|
|
7299
|
+
additionalProperties: false,
|
|
7300
|
+
},
|
|
7301
|
+
},
|
|
7302
|
+
{
|
|
7303
|
+
name: "fs_diff",
|
|
7304
|
+
description: "Show the unified diff of your changes so you can self-verify BEFORE committing/pushing — essential after large or multi-step edits. Default compares the working tree to the last commit (HEAD). Pass ref (e.g. 'origin/develop') to compare against a remote branch and catch staleness — i.e. see exactly how your working tree differs from what's on the remote, in one call. Pass staged=true to see only what's staged. Returns a --stat summary plus the patch (capped at 60KB; `truncated` flags when hit). Requires 'g' (git) access on the mount.",
|
|
7305
|
+
inputSchema: {
|
|
7306
|
+
type: "object",
|
|
7307
|
+
properties: {
|
|
7308
|
+
paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to limit the diff to. Omit for all changes." },
|
|
7309
|
+
ref: { type: "string", description: "Compare the working tree against this ref instead of HEAD (e.g. 'origin/develop', a tag, or a commit sha). Use for remote/staleness comparison." },
|
|
7310
|
+
staged: { type: "boolean", description: "Show only staged changes (index vs HEAD) instead of the full working-tree diff." },
|
|
7311
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
7312
|
+
},
|
|
7313
|
+
additionalProperties: false,
|
|
7314
|
+
},
|
|
7315
|
+
},
|
|
7147
7316
|
];
|
|
7148
7317
|
|
|
7318
|
+
const MCP_WRITE_TOOL_NAMES = new Set([
|
|
7319
|
+
"clauth_enable",
|
|
7320
|
+
"clauth_disable",
|
|
7321
|
+
"clauth_set_project",
|
|
7322
|
+
"clauth_generate_token",
|
|
7323
|
+
]);
|
|
7324
|
+
|
|
7325
|
+
function filterMcpToolsForWriteMode(tools) {
|
|
7326
|
+
if (process.env.CLAUTH_MCP_WRITE === "1") return tools;
|
|
7327
|
+
return tools.filter(tool => !MCP_WRITE_TOOL_NAMES.has(tool.name));
|
|
7328
|
+
}
|
|
7329
|
+
|
|
7149
7330
|
function writeTempSecret(service, value) {
|
|
7150
7331
|
const filePath = path.join(os.tmpdir(), `.clauth-${service}`);
|
|
7151
7332
|
fs.writeFileSync(filePath, value, { mode: 0o600 });
|
|
@@ -7176,6 +7357,11 @@ function mcpError(text) {
|
|
|
7176
7357
|
const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
|
|
7177
7358
|
|
|
7178
7359
|
async function handleMcpTool(vault, name, args) {
|
|
7360
|
+
const requireMcpWrite = () => {
|
|
7361
|
+
if (vault.writeEnabled) return null;
|
|
7362
|
+
return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
|
|
7363
|
+
};
|
|
7364
|
+
|
|
7179
7365
|
switch (name) {
|
|
7180
7366
|
case "clauth_ping": {
|
|
7181
7367
|
return mcpResult(
|
|
@@ -7357,6 +7543,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7357
7543
|
}
|
|
7358
7544
|
|
|
7359
7545
|
case "clauth_enable": {
|
|
7546
|
+
const writeError = requireMcpWrite();
|
|
7547
|
+
if (writeError) return writeError;
|
|
7360
7548
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7361
7549
|
const service = (args.service || "").toLowerCase();
|
|
7362
7550
|
if (!service) return mcpError("service is required");
|
|
@@ -7371,6 +7559,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7371
7559
|
}
|
|
7372
7560
|
|
|
7373
7561
|
case "clauth_disable": {
|
|
7562
|
+
const writeError = requireMcpWrite();
|
|
7563
|
+
if (writeError) return writeError;
|
|
7374
7564
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7375
7565
|
const service = (args.service || "").toLowerCase();
|
|
7376
7566
|
if (!service) return mcpError("service is required");
|
|
@@ -7410,6 +7600,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7410
7600
|
}
|
|
7411
7601
|
|
|
7412
7602
|
case "clauth_set_project": {
|
|
7603
|
+
const writeError = requireMcpWrite();
|
|
7604
|
+
if (writeError) return writeError;
|
|
7413
7605
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7414
7606
|
const service = (args.service || "").toLowerCase();
|
|
7415
7607
|
const project = args.project;
|
|
@@ -7461,6 +7653,8 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7461
7653
|
}
|
|
7462
7654
|
|
|
7463
7655
|
case "clauth_generate_token": {
|
|
7656
|
+
const writeError = requireMcpWrite();
|
|
7657
|
+
if (writeError) return writeError;
|
|
7464
7658
|
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7465
7659
|
const service = (args.service || "").trim().toLowerCase();
|
|
7466
7660
|
const prefix = args.prefix || "";
|
|
@@ -7468,8 +7662,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7468
7662
|
try {
|
|
7469
7663
|
const randomHex = crypto.randomBytes(32).toString("hex");
|
|
7470
7664
|
const generatedToken = `${prefix}${randomHex}`;
|
|
7471
|
-
const {
|
|
7472
|
-
|
|
7665
|
+
const { result } = await writeCredentialWithRecovery({
|
|
7666
|
+
password: vault.password,
|
|
7667
|
+
machineHash: vault.machineHash,
|
|
7668
|
+
service,
|
|
7669
|
+
value: generatedToken,
|
|
7670
|
+
logFile: LOG_FILE,
|
|
7671
|
+
normalize: false,
|
|
7672
|
+
});
|
|
7473
7673
|
if (result.error) return mcpError(result.error);
|
|
7474
7674
|
return mcpResult(`Token generated and stored under "${service}": ${generatedToken}`);
|
|
7475
7675
|
} catch (err) {
|
|
@@ -8058,8 +8258,87 @@ async function handleMcpTool(vault, name, args) {
|
|
|
8058
8258
|
case "fs_mounts": {
|
|
8059
8259
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
8060
8260
|
if (error) return mcpError(error);
|
|
8061
|
-
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\": \"
|
|
8062
|
-
|
|
8261
|
+
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)");
|
|
8262
|
+
// Decode the access string so callers know what's possible BEFORE trying —
|
|
8263
|
+
// notably `git` (the git verbs require it; absent = explain how to enable).
|
|
8264
|
+
const decorated = mounts.map((m) => {
|
|
8265
|
+
const a = String(m.access || "");
|
|
8266
|
+
return { ...m, can: { read: a.includes("r"), write: a.includes("w"), delete: a.includes("d"), git: a.includes("g") } };
|
|
8267
|
+
});
|
|
8268
|
+
return mcpResult(JSON.stringify(decorated, null, 2));
|
|
8269
|
+
}
|
|
8270
|
+
|
|
8271
|
+
case "fs_repo_status": {
|
|
8272
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8273
|
+
if (r.error) return mcpError(r.error);
|
|
8274
|
+
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.");
|
|
8275
|
+
try {
|
|
8276
|
+
const { result, error } = await fsGit.repoStatus(r.resolved);
|
|
8277
|
+
if (error) return mcpError(error);
|
|
8278
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8279
|
+
} catch (err) {
|
|
8280
|
+
return mcpError(`Status failed: ${err.message}`);
|
|
8281
|
+
}
|
|
8282
|
+
}
|
|
8283
|
+
|
|
8284
|
+
case "fs_use_branch": {
|
|
8285
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8286
|
+
if (r.error) return mcpError(r.error);
|
|
8287
|
+
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.");
|
|
8288
|
+
try {
|
|
8289
|
+
const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
|
|
8290
|
+
if (error) return mcpError(error);
|
|
8291
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8292
|
+
} catch (err) {
|
|
8293
|
+
return mcpError(`Branch switch failed: ${err.message}`);
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
|
|
8297
|
+
case "fs_commit": {
|
|
8298
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8299
|
+
if (r.error) return mcpError(r.error);
|
|
8300
|
+
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.");
|
|
8301
|
+
const push = args.push !== false;
|
|
8302
|
+
const dryRun = args.dry_run === true;
|
|
8303
|
+
// Fetch the push token from the vault up front (commit happens first, so
|
|
8304
|
+
// a missing token still preserves the local commit). Skipped on dry-run.
|
|
8305
|
+
let token = null, tokenError = null;
|
|
8306
|
+
if (push && !dryRun) {
|
|
8307
|
+
const sec = await vaultRetrieveValue(vault, "github");
|
|
8308
|
+
if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
|
|
8309
|
+
else token = sec.value;
|
|
8310
|
+
}
|
|
8311
|
+
try {
|
|
8312
|
+
const { result, error } = await fsGit.commit(r.resolved, {
|
|
8313
|
+
message: args.message,
|
|
8314
|
+
paths: args.paths,
|
|
8315
|
+
push,
|
|
8316
|
+
dryRun,
|
|
8317
|
+
expectedHead: args.expected_head,
|
|
8318
|
+
remote: args.remote,
|
|
8319
|
+
token,
|
|
8320
|
+
tokenError,
|
|
8321
|
+
authorName: args.author_name,
|
|
8322
|
+
authorEmail: args.author_email,
|
|
8323
|
+
});
|
|
8324
|
+
if (error) return mcpError(error);
|
|
8325
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8326
|
+
} catch (err) {
|
|
8327
|
+
return mcpError(`Commit failed: ${err.message}`);
|
|
8328
|
+
}
|
|
8329
|
+
}
|
|
8330
|
+
|
|
8331
|
+
case "fs_diff": {
|
|
8332
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
8333
|
+
if (r.error) return mcpError(r.error);
|
|
8334
|
+
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.");
|
|
8335
|
+
try {
|
|
8336
|
+
const { result, error } = await fsGit.diff(r.resolved, { paths: args.paths, ref: args.ref, staged: args.staged === true });
|
|
8337
|
+
if (error) return mcpError(error);
|
|
8338
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
8339
|
+
} catch (err) {
|
|
8340
|
+
return mcpError(`Diff failed: ${err.message}`);
|
|
8341
|
+
}
|
|
8063
8342
|
}
|
|
8064
8343
|
|
|
8065
8344
|
case "monkey_dispatch": {
|
|
@@ -8251,6 +8530,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
8251
8530
|
whitelist,
|
|
8252
8531
|
failCount: 0,
|
|
8253
8532
|
MAX_FAILS: 10,
|
|
8533
|
+
writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
|
|
8254
8534
|
};
|
|
8255
8535
|
|
|
8256
8536
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
@@ -8289,7 +8569,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
8289
8569
|
if (msg.method === "tools/list") {
|
|
8290
8570
|
return send({
|
|
8291
8571
|
jsonrpc: "2.0", id,
|
|
8292
|
-
result: { tools: MCP_TOOLS }
|
|
8572
|
+
result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
|
|
8293
8573
|
});
|
|
8294
8574
|
}
|
|
8295
8575
|
|