@lifeaitools/clauth 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,15 @@ function isProcessAlive(pid) {
41
41
  try { process.kill(pid, 0); return true; } catch { return false; }
42
42
  }
43
43
 
44
+ function openBrowser(url) {
45
+ try {
46
+ const cmd = os.platform() === "win32" ? `start "" "${url}"`
47
+ : os.platform() === "darwin" ? `open "${url}"`
48
+ : `xdg-open "${url}"`;
49
+ execSync(cmd, { stdio: "ignore" });
50
+ } catch {}
51
+ }
52
+
44
53
  // ── Dashboard HTML ───────────────────────────────────────────
45
54
  function dashboardHtml(port, whitelist) {
46
55
  return `<!DOCTYPE html>
@@ -127,6 +136,23 @@ function dashboardHtml(port, whitelist) {
127
136
  @keyframes sdot-fade{to{opacity:0}} .status-dot.fading{animation:sdot-fade 1.5s forwards}
128
137
  .error-bar{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b;padding:10px 14px;border-radius:8px;margin-bottom:1rem;display:none;font-size:.85rem}
129
138
  .loading{color:#64748b;font-style:italic}
139
+ .tunnel-panel{background:#0f1d2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
140
+ .tunnel-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
141
+ .tunnel-dot.off{background:#64748b}
142
+ .tunnel-dot.starting{background:#f59e0b;animation:pulse 1s infinite}
143
+ .tunnel-dot.on{background:#22c55e;animation:pulse 2s infinite}
144
+ .tunnel-dot.err{background:#ef4444}
145
+ .tunnel-label{font-size:.85rem;color:#94a3b8;flex:1}
146
+ .tunnel-label strong{color:#e2e8f0}
147
+ .tunnel-url{font-family:'Courier New',monospace;font-size:.78rem;color:#60a5fa;word-break:break-all}
148
+ .tunnel-url a{color:#60a5fa;text-decoration:none}
149
+ .tunnel-url a:hover{text-decoration:underline}
150
+ .btn-claude{background:linear-gradient(135deg,#d97706,#f59e0b);color:#0a0f1a;padding:8px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:700;letter-spacing:.3px;transition:all .15s;white-space:nowrap}
151
+ .btn-claude:hover{filter:brightness(1.1);transform:translateY(-1px)}
152
+ .btn-claude:disabled{opacity:.4;cursor:not-allowed;transform:none;filter:none}
153
+ .btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
154
+ .btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
155
+ .tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
130
156
  .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
131
157
  .oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
132
158
  .oauth-field{display:flex;flex-direction:column;gap:3px}
@@ -189,6 +215,17 @@ function dashboardHtml(port, whitelist) {
189
215
  </div>
190
216
  </div>
191
217
 
218
+ <div class="tunnel-panel" id="tunnel-panel">
219
+ <div class="tunnel-dot off" id="tunnel-dot"></div>
220
+ <div class="tunnel-label" id="tunnel-label">
221
+ <strong>claude.ai MCP</strong> — checking tunnel…
222
+ </div>
223
+ <button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
224
+ <button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
225
+ <button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
226
+ <div class="tunnel-err" id="tunnel-err" style="display:none"></div>
227
+ </div>
228
+
192
229
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
193
230
  <div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
194
231
  </div>
@@ -303,6 +340,7 @@ function showMain(ping) {
303
340
  document.getElementById("s-fails").textContent =
304
341
  ping.failures + "/" + (ping.failures + ping.failures_remaining);
305
342
  }
343
+ pollTunnel();
306
344
  }
307
345
 
308
346
  // ── Unlock ──────────────────────────────────
@@ -562,6 +600,22 @@ async function checkAll() {
562
600
 
563
601
  const results = r.results || {};
564
602
  for (const [name, result] of Object.entries(results)) {
603
+ if (name === "__tunnel__") {
604
+ // Update tunnel panel with health result
605
+ const tDot = document.getElementById("tunnel-dot");
606
+ const tLabel = document.getElementById("tunnel-label");
607
+ if (result.ok) {
608
+ tDot.className = "tunnel-dot on";
609
+ tLabel.innerHTML = '<strong>claude.ai MCP</strong> — tunnel healthy';
610
+ } else if (result.running) {
611
+ tDot.className = "tunnel-dot starting";
612
+ tLabel.innerHTML = '<strong>claude.ai MCP</strong> — ' + (result.reason || "starting…");
613
+ } else {
614
+ tDot.className = "tunnel-dot err";
615
+ tLabel.innerHTML = '<strong>claude.ai MCP</strong> — ' + (result.reason || "not running");
616
+ }
617
+ continue;
618
+ }
565
619
  const dot = document.getElementById("sdot-" + name);
566
620
  if (!dot) continue;
567
621
  if (result.ok) {
@@ -639,6 +693,140 @@ document.addEventListener("DOMContentLoaded", () => {
639
693
  });
640
694
  });
641
695
 
696
+ // ── Tunnel management ───────────────────────
697
+ let tunnelPollTimer = null;
698
+
699
+ async function pollTunnel() {
700
+ if (tunnelPollTimer) clearInterval(tunnelPollTimer);
701
+ await updateTunnelUI();
702
+ // Poll every 3s until URL is found, then slow to 10s
703
+ tunnelPollTimer = setInterval(async () => {
704
+ const state = await updateTunnelUI();
705
+ if (state === "connected") {
706
+ clearInterval(tunnelPollTimer);
707
+ tunnelPollTimer = setInterval(updateTunnelUI, 10000);
708
+ }
709
+ }, 3000);
710
+ }
711
+
712
+ async function updateTunnelUI() {
713
+ const dot = document.getElementById("tunnel-dot");
714
+ const label = document.getElementById("tunnel-label");
715
+ const btn = document.getElementById("btn-claude");
716
+ const togBtn = document.getElementById("btn-tunnel-toggle");
717
+ const errEl = document.getElementById("tunnel-err");
718
+
719
+ try {
720
+ const t = await fetch(BASE + "/tunnel").then(r => r.json());
721
+
722
+ errEl.style.display = "none";
723
+
724
+ if (t.error) {
725
+ dot.className = "tunnel-dot err";
726
+ label.innerHTML = '<strong>claude.ai MCP</strong> — ' + t.error;
727
+ btn.disabled = true;
728
+ togBtn.style.display = "none";
729
+ togBtn.textContent = "Start Tunnel";
730
+ togBtn.onclick = () => toggleTunnel("start");
731
+ togBtn.style.display = "inline-block";
732
+ return "error";
733
+ }
734
+
735
+ if (t.running && t.url && t.url.startsWith("http")) {
736
+ dot.className = "tunnel-dot on";
737
+ label.innerHTML = '<strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="' + t.sseUrl + '" target="_blank">' + t.sseUrl + '</a></span>';
738
+ btn.disabled = false;
739
+ document.getElementById("btn-tunnel-test").style.display = "inline-block";
740
+ togBtn.textContent = "Stop Tunnel";
741
+ togBtn.onclick = () => toggleTunnel("stop");
742
+ togBtn.style.display = "inline-block";
743
+ return "connected";
744
+ }
745
+
746
+ if (t.running) {
747
+ dot.className = "tunnel-dot starting";
748
+ label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
749
+ btn.disabled = true;
750
+ togBtn.textContent = "Stop Tunnel";
751
+ togBtn.onclick = () => toggleTunnel("stop");
752
+ togBtn.style.display = "inline-block";
753
+ return "starting";
754
+ }
755
+
756
+ // Not running
757
+ dot.className = "tunnel-dot off";
758
+ label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
759
+ btn.disabled = true;
760
+ togBtn.textContent = "Start Tunnel";
761
+ togBtn.onclick = () => toggleTunnel("start");
762
+ togBtn.style.display = "inline-block";
763
+ return "stopped";
764
+
765
+ } catch {
766
+ dot.className = "tunnel-dot off";
767
+ label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
768
+ btn.disabled = true;
769
+ togBtn.style.display = "none";
770
+ return "error";
771
+ }
772
+ }
773
+
774
+ async function toggleTunnel(action) {
775
+ const togBtn = document.getElementById("btn-tunnel-toggle");
776
+ togBtn.disabled = true; togBtn.textContent = "…";
777
+ try {
778
+ await fetch(BASE + "/tunnel", {
779
+ method: "POST",
780
+ headers: { "Content-Type": "application/json" },
781
+ body: JSON.stringify({ action })
782
+ });
783
+ // Wait a beat for tunnel to start/stop, then refresh
784
+ setTimeout(updateTunnelUI, action === "start" ? 5000 : 500);
785
+ } catch {} finally {
786
+ togBtn.disabled = false;
787
+ }
788
+ }
789
+
790
+ async function testTunnel() {
791
+ const btn = document.getElementById("btn-tunnel-test");
792
+ const dot = document.getElementById("tunnel-dot");
793
+ const label = document.getElementById("tunnel-label");
794
+ btn.disabled = true; btn.textContent = "Testing…";
795
+ dot.className = "tunnel-dot starting";
796
+
797
+ try {
798
+ const r = await fetch(BASE + "/tunnel/test").then(r => r.json());
799
+ if (r.ok) {
800
+ dot.className = "tunnel-dot on";
801
+ label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#4ade80">PASS</span> ' +
802
+ r.latencyMs + 'ms roundtrip · SSE ' + (r.sseReachable ? 'reachable' : 'unreachable') +
803
+ ' · <span class="tunnel-url"><a href="' + r.sseUrl + '" target="_blank">' + r.sseUrl + '</a></span>';
804
+ } else {
805
+ dot.className = "tunnel-dot err";
806
+ label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + (r.reason || "unknown error");
807
+ }
808
+ } catch (e) {
809
+ dot.className = "tunnel-dot err";
810
+ label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
811
+ } finally {
812
+ btn.disabled = false; btn.textContent = "Test";
813
+ }
814
+ }
815
+
816
+ function openClaude() {
817
+ // Copy the SSE URL to clipboard and open claude.ai settings
818
+ fetch(BASE + "/tunnel").then(r => r.json()).then(t => {
819
+ if (t.sseUrl) {
820
+ navigator.clipboard.writeText(t.sseUrl).then(() => {
821
+ const btn = document.getElementById("btn-claude");
822
+ btn.textContent = "SSE URL copied!";
823
+ setTimeout(() => { btn.textContent = "Connect claude.ai"; }, 2000);
824
+ }).catch(() => {});
825
+ window.open("https://claude.ai/settings/integrations", "_blank");
826
+ }
827
+ });
828
+ }
829
+
642
830
  boot();
643
831
  </script>
644
832
  </body>
@@ -657,6 +845,16 @@ function readBody(req) {
657
845
 
658
846
  // ── Server logic (shared by foreground + daemon) ─────────────
659
847
  function createServer(initPassword, whitelist, port) {
848
+ // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
849
+ if (os.platform() === "win32") {
850
+ const sys32 = "C:\\Windows\\System32";
851
+ if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
852
+ process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
853
+ }
854
+ if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
855
+ process.env.PATH = (process.env.PATH || "") + ";" + sys32;
856
+ }
857
+ }
660
858
  const MAX_FAILS = 3;
661
859
  let failCount = 0;
662
860
  let password = initPassword || null; // null = locked; set via POST /auth
@@ -668,6 +866,138 @@ function createServer(initPassword, whitelist, port) {
668
866
  "Access-Control-Allow-Headers": "Content-Type",
669
867
  };
670
868
 
869
+ // ── MCP SSE session tracking ──────────────────────────────
870
+ const sseSessions = new Map(); // sessionId → { res, initialized }
871
+ let sseCounter = 0;
872
+
873
+ function sseVault() {
874
+ return {
875
+ _pw: password,
876
+ get password() { return this._pw; },
877
+ set password(v) { this._pw = v; },
878
+ get machineHash() { return machineHash; },
879
+ whitelist,
880
+ failCount,
881
+ MAX_FAILS,
882
+ };
883
+ }
884
+
885
+ function sseSend(sessionRes, event, data) {
886
+ sessionRes.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
887
+ }
888
+
889
+ // ── Cloudflare Tunnel management ──────────────────────────
890
+ let tunnelProc = null;
891
+ let tunnelUrl = null;
892
+ let tunnelError = null;
893
+
894
+ async function startTunnel() {
895
+ if (tunnelProc) return; // already running
896
+ tunnelUrl = null;
897
+ tunnelError = null;
898
+
899
+ try {
900
+ // Named tunnel "clauth" — ingress configured in ~/.cloudflared/config.yml
901
+ const tunnelName = "clauth";
902
+
903
+ // Resolve cloudflared binary — may not be on PATH in bash shells
904
+ let cfBin = "cloudflared";
905
+ if (os.platform() === "win32") {
906
+ const candidates = [
907
+ "cloudflared",
908
+ path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
909
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
910
+ path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
911
+ "C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
912
+ ];
913
+ for (const c of candidates) {
914
+ try { if (fs.statSync(c).isFile()) { cfBin = c; break; } } catch {}
915
+ }
916
+ }
917
+
918
+ const proc = spawnProc(cfBin, ["tunnel", "run", tunnelName], {
919
+ stdio: ["ignore", "pipe", "pipe"],
920
+ });
921
+
922
+ tunnelProc = proc;
923
+
924
+ // cloudflared logs to stderr — parse for connection info
925
+ let stderrBuf = "";
926
+ proc.stderr.on("data", (chunk) => {
927
+ const text = chunk.toString();
928
+ stderrBuf += text;
929
+
930
+ // Detect connection registered (named tunnel is live)
931
+ if (!tunnelUrl && stderrBuf.includes("Registered tunnel connection")) {
932
+ // Named tunnel URL comes from config, not stderr.
933
+ // Read it from cloudflared config if available.
934
+ try {
935
+ const cfgPath = path.join(os.homedir(), ".cloudflared", "config.yml");
936
+ const cfgText = fs.readFileSync(cfgPath, "utf8");
937
+ const hostnameMatch = cfgText.match(/hostname:\s*(\S+)/);
938
+ if (hostnameMatch) {
939
+ tunnelUrl = hostnameMatch[1].startsWith("http") ? hostnameMatch[1] : `https://${hostnameMatch[1]}`;
940
+ }
941
+ } catch {}
942
+ if (!tunnelUrl) tunnelUrl = `(named tunnel "${tunnelName}" connected — check DNS)`;
943
+ const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
944
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
945
+ }
946
+
947
+ // Quick tunnel fallback: trycloudflare.com URL
948
+ if (!tunnelUrl) {
949
+ const quickMatch = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
950
+ if (quickMatch) {
951
+ tunnelUrl = quickMatch[0];
952
+ const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
953
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
954
+ }
955
+ }
956
+ });
957
+
958
+ proc.on("error", (err) => {
959
+ tunnelError = err.code === "ENOENT"
960
+ ? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
961
+ : err.message;
962
+ tunnelProc = null;
963
+ const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
964
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
965
+ });
966
+
967
+ proc.on("exit", (code) => {
968
+ if (tunnelProc === proc) {
969
+ tunnelProc = null;
970
+ if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
971
+ const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
972
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
973
+ }
974
+ });
975
+
976
+ // Give it a moment to start
977
+ await new Promise(r => setTimeout(r, 4000));
978
+
979
+ } catch (err) {
980
+ tunnelError = err.message;
981
+ tunnelProc = null;
982
+ }
983
+ }
984
+
985
+ function stopTunnel() {
986
+ if (tunnelProc) {
987
+ try { tunnelProc.kill(); } catch {}
988
+ tunnelProc = null;
989
+ tunnelUrl = null;
990
+ tunnelError = null;
991
+ const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
992
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
993
+ }
994
+ }
995
+
996
+ // Auto-start tunnel if vault is already unlocked (--pw flag)
997
+ if (password) {
998
+ startTunnel().catch(() => {});
999
+ }
1000
+
671
1001
  function strike(res, code, message) {
672
1002
  failCount++;
673
1003
  const remaining = MAX_FAILS - failCount;
@@ -715,6 +1045,116 @@ function createServer(initPassword, whitelist, port) {
715
1045
  const reqPath = url.pathname;
716
1046
  const method = req.method;
717
1047
 
1048
+ // ── MCP SSE transport ─────────────────────────────────
1049
+ // GET /sse — open SSE stream, receive endpoint event
1050
+ if (method === "GET" && reqPath === "/sse") {
1051
+ const sessionId = `ses_${++sseCounter}_${Date.now()}`;
1052
+
1053
+ res.writeHead(200, {
1054
+ "Content-Type": "text/event-stream",
1055
+ "Cache-Control": "no-cache",
1056
+ "Connection": "keep-alive",
1057
+ ...CORS,
1058
+ });
1059
+
1060
+ sseSessions.set(sessionId, { res, initialized: false });
1061
+
1062
+ // Send the endpoint URI the client should POST to
1063
+ const endpoint = `/message?sessionId=${sessionId}`;
1064
+ res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
1065
+
1066
+ // Keepalive every 15s
1067
+ const keepalive = setInterval(() => {
1068
+ try { res.write(": keepalive\n\n"); } catch {}
1069
+ }, 15_000);
1070
+
1071
+ // Cleanup on disconnect
1072
+ req.on("close", () => {
1073
+ clearInterval(keepalive);
1074
+ sseSessions.delete(sessionId);
1075
+ const logLine = `[${new Date().toISOString()}] SSE session closed: ${sessionId}\n`;
1076
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1077
+ });
1078
+
1079
+ const logLine = `[${new Date().toISOString()}] SSE session opened: ${sessionId}\n`;
1080
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1081
+ return;
1082
+ }
1083
+
1084
+ // POST /message?sessionId=xxx — JSON-RPC over SSE
1085
+ if (method === "POST" && reqPath === "/message") {
1086
+ const sessionId = url.searchParams.get("sessionId");
1087
+ const session = sessionId ? sseSessions.get(sessionId) : null;
1088
+
1089
+ if (!session) {
1090
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
1091
+ return res.end(JSON.stringify({ error: "Unknown or expired session" }));
1092
+ }
1093
+
1094
+ let body;
1095
+ try { body = await readBody(req); } catch {
1096
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1097
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
1098
+ }
1099
+
1100
+ const id = body.id;
1101
+ const rpcMethod = body.method;
1102
+
1103
+ // Acknowledge the POST immediately
1104
+ res.writeHead(202, { "Content-Type": "application/json", ...CORS });
1105
+ res.end(JSON.stringify({ ok: true }));
1106
+
1107
+ // Handle JSON-RPC methods
1108
+ if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
1109
+ session.initialized = true;
1110
+ return;
1111
+ }
1112
+
1113
+ if (rpcMethod === "initialize") {
1114
+ sseSend(session.res, "message", {
1115
+ jsonrpc: "2.0", id,
1116
+ result: {
1117
+ protocolVersion: "2024-11-05",
1118
+ serverInfo: { name: "clauth", version: VERSION },
1119
+ capabilities: { tools: {} }
1120
+ }
1121
+ });
1122
+ return;
1123
+ }
1124
+
1125
+ if (rpcMethod === "tools/list") {
1126
+ sseSend(session.res, "message", {
1127
+ jsonrpc: "2.0", id,
1128
+ result: { tools: MCP_TOOLS }
1129
+ });
1130
+ return;
1131
+ }
1132
+
1133
+ if (rpcMethod === "tools/call") {
1134
+ const { name, arguments: args } = body.params || {};
1135
+ const vault = sseVault();
1136
+ try {
1137
+ const result = await handleMcpTool(vault, name, args || {});
1138
+ // Sync vault mutations back (e.g. unlock sets password)
1139
+ password = vault.password;
1140
+ sseSend(session.res, "message", { jsonrpc: "2.0", id, result });
1141
+ } catch (err) {
1142
+ sseSend(session.res, "message", {
1143
+ jsonrpc: "2.0", id,
1144
+ result: mcpError(`Internal error: ${err.message}`)
1145
+ });
1146
+ }
1147
+ return;
1148
+ }
1149
+
1150
+ // Unknown method
1151
+ sseSend(session.res, "message", {
1152
+ jsonrpc: "2.0", id,
1153
+ error: { code: -32601, message: `Unknown method: ${rpcMethod}` }
1154
+ });
1155
+ return;
1156
+ }
1157
+
718
1158
  // GET / — built-in web dashboard
719
1159
  if (method === "GET" && reqPath === "/") {
720
1160
  res.writeHead(200, { "Content-Type": "text/html", ...CORS });
@@ -734,8 +1174,36 @@ function createServer(initPassword, whitelist, port) {
734
1174
  });
735
1175
  }
736
1176
 
1177
+ // GET /tunnel — tunnel status (for dashboard polling)
1178
+ if (method === "GET" && reqPath === "/tunnel") {
1179
+ return ok(res, {
1180
+ running: !!tunnelProc,
1181
+ url: tunnelUrl,
1182
+ sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
1183
+ error: tunnelError,
1184
+ });
1185
+ }
1186
+
1187
+ // POST /tunnel — start or stop tunnel manually
1188
+ if (method === "POST" && reqPath === "/tunnel") {
1189
+ if (lockedGuard(res)) return;
1190
+ let body;
1191
+ try { body = await readBody(req); } catch {
1192
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1193
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
1194
+ }
1195
+ if (body.action === "stop") {
1196
+ stopTunnel();
1197
+ return ok(res, { ok: true, running: false });
1198
+ }
1199
+ // start
1200
+ await startTunnel();
1201
+ return ok(res, { ok: true, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
1202
+ }
1203
+
737
1204
  // GET /shutdown (for daemon stop)
738
1205
  if (method === "GET" && reqPath === "/shutdown") {
1206
+ stopTunnel();
739
1207
  ok(res, { ok: true, message: "shutting down" });
740
1208
  removePid();
741
1209
  setTimeout(() => process.exit(0), 100);
@@ -811,6 +1279,8 @@ function createServer(initPassword, whitelist, port) {
811
1279
  password = pw; // unlock — store in process memory only
812
1280
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
813
1281
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1282
+ // Auto-start Cloudflare Tunnel for claude.ai MCP
1283
+ startTunnel().catch(() => {});
814
1284
  return ok(res, { ok: true, locked: false });
815
1285
  } catch {
816
1286
  // Wrong password — not a lockout strike, just a UI auth attempt
@@ -822,6 +1292,7 @@ function createServer(initPassword, whitelist, port) {
822
1292
  // POST /lock — clear password from memory
823
1293
  if (method === "POST" && reqPath === "/lock") {
824
1294
  password = null;
1295
+ stopTunnel();
825
1296
  const logLine = `[${new Date().toISOString()}] Vault locked\n`;
826
1297
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
827
1298
  return ok(res, { ok: true, locked: true });
@@ -879,12 +1350,62 @@ function createServer(initPassword, whitelist, port) {
879
1350
  results[svc.name] = { ok: false, reason: e.message };
880
1351
  }
881
1352
  }
1353
+ // Include tunnel health in check-all
1354
+ const tunnelHealth = { running: !!tunnelProc, url: tunnelUrl, error: tunnelError };
1355
+ if (tunnelProc && tunnelUrl && tunnelUrl.startsWith("http")) {
1356
+ try {
1357
+ const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(5000) });
1358
+ const data = await resp.json();
1359
+ tunnelHealth.ok = data.status === "ok";
1360
+ tunnelHealth.latencyMs = null; // could measure but ping is fast
1361
+ } catch (e) {
1362
+ tunnelHealth.ok = false;
1363
+ tunnelHealth.reason = e.message;
1364
+ }
1365
+ } else {
1366
+ tunnelHealth.ok = false;
1367
+ tunnelHealth.reason = tunnelError || (tunnelProc ? "URL not yet available" : "Not running");
1368
+ }
1369
+ results["__tunnel__"] = tunnelHealth;
1370
+
882
1371
  return ok(res, { results });
883
1372
  } catch (err) {
884
1373
  return strike(res, 502, err.message);
885
1374
  }
886
1375
  }
887
1376
 
1377
+ // GET /tunnel/test — end-to-end tunnel health check (hits /ping through the tunnel)
1378
+ if (method === "GET" && reqPath === "/tunnel/test") {
1379
+ if (lockedGuard(res)) return;
1380
+ if (!tunnelUrl || !tunnelUrl.startsWith("http")) {
1381
+ return ok(res, { ok: false, reason: "Tunnel not connected" });
1382
+ }
1383
+ const start = Date.now();
1384
+ try {
1385
+ // Hit /ping through the public tunnel URL — proves full roundtrip
1386
+ const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(8000) });
1387
+ const data = await resp.json();
1388
+ const latencyMs = Date.now() - start;
1389
+ if (data.status === "ok") {
1390
+ // Also verify SSE endpoint is reachable
1391
+ const sseResp = await fetch(tunnelUrl + "/sse", { signal: AbortSignal.timeout(5000) });
1392
+ const sseOk = sseResp.headers.get("content-type")?.includes("text/event-stream");
1393
+ sseResp.body?.cancel?.();
1394
+ return ok(res, {
1395
+ ok: true,
1396
+ latencyMs,
1397
+ tunnelUrl,
1398
+ sseUrl: tunnelUrl + "/sse",
1399
+ sseReachable: !!sseOk,
1400
+ pid: data.pid,
1401
+ });
1402
+ }
1403
+ return ok(res, { ok: false, reason: "Ping returned unexpected response", data });
1404
+ } catch (e) {
1405
+ return ok(res, { ok: false, reason: e.message, latencyMs: Date.now() - start });
1406
+ }
1407
+ }
1408
+
888
1409
  // POST /change-pw — change master password (must be unlocked)
889
1410
  if (method === "POST" && reqPath === "/change-pw") {
890
1411
  if (lockedGuard(res)) return;
@@ -1066,6 +1587,8 @@ async function actionStart(opts) {
1066
1587
  console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
1067
1588
  }
1068
1589
  console.log(chalk.gray(` Stop: clauth serve stop\n`));
1590
+ // Auto-open browser
1591
+ openBrowser(`http://127.0.0.1:${info.port}`);
1069
1592
  } else {
1070
1593
  console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
1071
1594
  process.exit(1);
@@ -1176,6 +1699,8 @@ async function actionForeground(opts) {
1176
1699
  console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
1177
1700
  if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
1178
1701
  console.log(chalk.gray(" Ctrl+C to stop\n"));
1702
+ // Auto-open browser
1703
+ openBrowser(`http://127.0.0.1:${port}`);
1179
1704
  });
1180
1705
 
1181
1706
  server.on("error", err => {
@@ -1200,7 +1725,7 @@ async function actionForeground(opts) {
1200
1725
  // Secrets are delivered via temp files — never in the MCP response.
1201
1726
 
1202
1727
  import { createInterface } from "readline";
1203
- import { execSync } from "child_process";
1728
+ import { execSync, spawn as spawnProc } from "child_process";
1204
1729
 
1205
1730
  const ENV_MAP = {
1206
1731
  "github": "GITHUB_TOKEN",