@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.
@@ -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 { token: wt, timestamp: wts } = deriveToken(password, machineHash);
314
- const writeResult = await api.write(password, machineHash, wt, wts, serviceName, result.newKey);
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.trim() : "";
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
- if (p === "/gws") return MCP_TOOLS.filter(t => t.name.startsWith("gws_"));
3119
- if (p === "/clauth") return MCP_TOOLS.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
3120
- if (p === "/fs") return MCP_TOOLS.filter(t => t.name.startsWith("fs_"));
3121
- if (p === "/chitchat") return MCP_TOOLS.filter(t => t.name.startsWith("chitchat_"));
3122
- if (p === "/codevelop") return MCP_TOOLS.filter(t => t.name.startsWith("codevelop_"));
3123
- return MCP_TOOLS; // /mcp all tools
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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 api.write (same as /set/:service)
4712
- const { token: t, timestamp } = deriveToken(password, machineHash);
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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 { token, timestamp } = deriveToken(password, machineHash);
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 (lockedGuard(res)) return;
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 { token: authToken, timestamp } = deriveToken(password, machineHash);
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 (lockedGuard(res)) return;
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 (lockedGuard(res)) return;
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\": \"rwd\"}" };
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 { token: authToken, timestamp } = deriveToken(vault.password, vault.machineHash);
7472
- const result = await api.write(vault.password, vault.machineHash, authToken, timestamp, service, generatedToken);
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\": \"rwd\"}");
8062
- return mcpResult(JSON.stringify(mounts, null, 2));
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