@lifeaitools/clauth 1.30.3 → 1.30.4

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