@lifeaitools/clauth 0.4.0 → 0.5.0

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,123 @@ 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
+ // Falls back to quick tunnel if named tunnel fails
902
+ const tunnelName = "clauth";
903
+ const proc = spawnProc("cloudflared", ["tunnel", "run", tunnelName], {
904
+ stdio: ["ignore", "pipe", "pipe"],
905
+ });
906
+
907
+ tunnelProc = proc;
908
+
909
+ // cloudflared logs to stderr — parse for connection info
910
+ let stderrBuf = "";
911
+ proc.stderr.on("data", (chunk) => {
912
+ const text = chunk.toString();
913
+ stderrBuf += text;
914
+
915
+ // Detect connection registered (named tunnel is live)
916
+ if (!tunnelUrl && stderrBuf.includes("Registered tunnel connection")) {
917
+ // Named tunnel URL comes from config, not stderr.
918
+ // Read it from cloudflared config if available.
919
+ try {
920
+ const cfgPath = path.join(os.homedir(), ".cloudflared", "config.yml");
921
+ const cfgText = fs.readFileSync(cfgPath, "utf8");
922
+ const hostnameMatch = cfgText.match(/hostname:\s*(\S+)/);
923
+ if (hostnameMatch) {
924
+ tunnelUrl = hostnameMatch[1].startsWith("http") ? hostnameMatch[1] : `https://${hostnameMatch[1]}`;
925
+ }
926
+ } catch {}
927
+ if (!tunnelUrl) tunnelUrl = `(named tunnel "${tunnelName}" connected — check DNS)`;
928
+ const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
929
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
930
+ }
931
+
932
+ // Quick tunnel fallback: trycloudflare.com URL
933
+ if (!tunnelUrl) {
934
+ const quickMatch = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
935
+ if (quickMatch) {
936
+ tunnelUrl = quickMatch[0];
937
+ const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
938
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
939
+ }
940
+ }
941
+ });
942
+
943
+ proc.on("error", (err) => {
944
+ tunnelError = err.code === "ENOENT"
945
+ ? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
946
+ : err.message;
947
+ tunnelProc = null;
948
+ const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
949
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
950
+ });
951
+
952
+ proc.on("exit", (code) => {
953
+ if (tunnelProc === proc) {
954
+ tunnelProc = null;
955
+ if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
956
+ const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
957
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
958
+ }
959
+ });
960
+
961
+ // Give it a moment to start
962
+ await new Promise(r => setTimeout(r, 4000));
963
+
964
+ } catch (err) {
965
+ tunnelError = err.message;
966
+ tunnelProc = null;
967
+ }
968
+ }
969
+
970
+ function stopTunnel() {
971
+ if (tunnelProc) {
972
+ try { tunnelProc.kill(); } catch {}
973
+ tunnelProc = null;
974
+ tunnelUrl = null;
975
+ tunnelError = null;
976
+ const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
977
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
978
+ }
979
+ }
980
+
981
+ // Auto-start tunnel if vault is already unlocked (--pw flag)
982
+ if (password) {
983
+ startTunnel().catch(() => {});
984
+ }
985
+
671
986
  function strike(res, code, message) {
672
987
  failCount++;
673
988
  const remaining = MAX_FAILS - failCount;
@@ -715,6 +1030,116 @@ function createServer(initPassword, whitelist, port) {
715
1030
  const reqPath = url.pathname;
716
1031
  const method = req.method;
717
1032
 
1033
+ // ── MCP SSE transport ─────────────────────────────────
1034
+ // GET /sse — open SSE stream, receive endpoint event
1035
+ if (method === "GET" && reqPath === "/sse") {
1036
+ const sessionId = `ses_${++sseCounter}_${Date.now()}`;
1037
+
1038
+ res.writeHead(200, {
1039
+ "Content-Type": "text/event-stream",
1040
+ "Cache-Control": "no-cache",
1041
+ "Connection": "keep-alive",
1042
+ ...CORS,
1043
+ });
1044
+
1045
+ sseSessions.set(sessionId, { res, initialized: false });
1046
+
1047
+ // Send the endpoint URI the client should POST to
1048
+ const endpoint = `/message?sessionId=${sessionId}`;
1049
+ res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
1050
+
1051
+ // Keepalive every 15s
1052
+ const keepalive = setInterval(() => {
1053
+ try { res.write(": keepalive\n\n"); } catch {}
1054
+ }, 15_000);
1055
+
1056
+ // Cleanup on disconnect
1057
+ req.on("close", () => {
1058
+ clearInterval(keepalive);
1059
+ sseSessions.delete(sessionId);
1060
+ const logLine = `[${new Date().toISOString()}] SSE session closed: ${sessionId}\n`;
1061
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1062
+ });
1063
+
1064
+ const logLine = `[${new Date().toISOString()}] SSE session opened: ${sessionId}\n`;
1065
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1066
+ return;
1067
+ }
1068
+
1069
+ // POST /message?sessionId=xxx — JSON-RPC over SSE
1070
+ if (method === "POST" && reqPath === "/message") {
1071
+ const sessionId = url.searchParams.get("sessionId");
1072
+ const session = sessionId ? sseSessions.get(sessionId) : null;
1073
+
1074
+ if (!session) {
1075
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
1076
+ return res.end(JSON.stringify({ error: "Unknown or expired session" }));
1077
+ }
1078
+
1079
+ let body;
1080
+ try { body = await readBody(req); } catch {
1081
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1082
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
1083
+ }
1084
+
1085
+ const id = body.id;
1086
+ const rpcMethod = body.method;
1087
+
1088
+ // Acknowledge the POST immediately
1089
+ res.writeHead(202, { "Content-Type": "application/json", ...CORS });
1090
+ res.end(JSON.stringify({ ok: true }));
1091
+
1092
+ // Handle JSON-RPC methods
1093
+ if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
1094
+ session.initialized = true;
1095
+ return;
1096
+ }
1097
+
1098
+ if (rpcMethod === "initialize") {
1099
+ sseSend(session.res, "message", {
1100
+ jsonrpc: "2.0", id,
1101
+ result: {
1102
+ protocolVersion: "2024-11-05",
1103
+ serverInfo: { name: "clauth", version: VERSION },
1104
+ capabilities: { tools: {} }
1105
+ }
1106
+ });
1107
+ return;
1108
+ }
1109
+
1110
+ if (rpcMethod === "tools/list") {
1111
+ sseSend(session.res, "message", {
1112
+ jsonrpc: "2.0", id,
1113
+ result: { tools: MCP_TOOLS }
1114
+ });
1115
+ return;
1116
+ }
1117
+
1118
+ if (rpcMethod === "tools/call") {
1119
+ const { name, arguments: args } = body.params || {};
1120
+ const vault = sseVault();
1121
+ try {
1122
+ const result = await handleMcpTool(vault, name, args || {});
1123
+ // Sync vault mutations back (e.g. unlock sets password)
1124
+ password = vault.password;
1125
+ sseSend(session.res, "message", { jsonrpc: "2.0", id, result });
1126
+ } catch (err) {
1127
+ sseSend(session.res, "message", {
1128
+ jsonrpc: "2.0", id,
1129
+ result: mcpError(`Internal error: ${err.message}`)
1130
+ });
1131
+ }
1132
+ return;
1133
+ }
1134
+
1135
+ // Unknown method
1136
+ sseSend(session.res, "message", {
1137
+ jsonrpc: "2.0", id,
1138
+ error: { code: -32601, message: `Unknown method: ${rpcMethod}` }
1139
+ });
1140
+ return;
1141
+ }
1142
+
718
1143
  // GET / — built-in web dashboard
719
1144
  if (method === "GET" && reqPath === "/") {
720
1145
  res.writeHead(200, { "Content-Type": "text/html", ...CORS });
@@ -734,8 +1159,36 @@ function createServer(initPassword, whitelist, port) {
734
1159
  });
735
1160
  }
736
1161
 
1162
+ // GET /tunnel — tunnel status (for dashboard polling)
1163
+ if (method === "GET" && reqPath === "/tunnel") {
1164
+ return ok(res, {
1165
+ running: !!tunnelProc,
1166
+ url: tunnelUrl,
1167
+ sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
1168
+ error: tunnelError,
1169
+ });
1170
+ }
1171
+
1172
+ // POST /tunnel — start or stop tunnel manually
1173
+ if (method === "POST" && reqPath === "/tunnel") {
1174
+ if (lockedGuard(res)) return;
1175
+ let body;
1176
+ try { body = await readBody(req); } catch {
1177
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
1178
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
1179
+ }
1180
+ if (body.action === "stop") {
1181
+ stopTunnel();
1182
+ return ok(res, { ok: true, running: false });
1183
+ }
1184
+ // start
1185
+ await startTunnel();
1186
+ return ok(res, { ok: true, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
1187
+ }
1188
+
737
1189
  // GET /shutdown (for daemon stop)
738
1190
  if (method === "GET" && reqPath === "/shutdown") {
1191
+ stopTunnel();
739
1192
  ok(res, { ok: true, message: "shutting down" });
740
1193
  removePid();
741
1194
  setTimeout(() => process.exit(0), 100);
@@ -811,6 +1264,8 @@ function createServer(initPassword, whitelist, port) {
811
1264
  password = pw; // unlock — store in process memory only
812
1265
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
813
1266
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1267
+ // Auto-start Cloudflare Tunnel for claude.ai MCP
1268
+ startTunnel().catch(() => {});
814
1269
  return ok(res, { ok: true, locked: false });
815
1270
  } catch {
816
1271
  // Wrong password — not a lockout strike, just a UI auth attempt
@@ -822,6 +1277,7 @@ function createServer(initPassword, whitelist, port) {
822
1277
  // POST /lock — clear password from memory
823
1278
  if (method === "POST" && reqPath === "/lock") {
824
1279
  password = null;
1280
+ stopTunnel();
825
1281
  const logLine = `[${new Date().toISOString()}] Vault locked\n`;
826
1282
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
827
1283
  return ok(res, { ok: true, locked: true });
@@ -879,12 +1335,62 @@ function createServer(initPassword, whitelist, port) {
879
1335
  results[svc.name] = { ok: false, reason: e.message };
880
1336
  }
881
1337
  }
1338
+ // Include tunnel health in check-all
1339
+ const tunnelHealth = { running: !!tunnelProc, url: tunnelUrl, error: tunnelError };
1340
+ if (tunnelProc && tunnelUrl && tunnelUrl.startsWith("http")) {
1341
+ try {
1342
+ const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(5000) });
1343
+ const data = await resp.json();
1344
+ tunnelHealth.ok = data.status === "ok";
1345
+ tunnelHealth.latencyMs = null; // could measure but ping is fast
1346
+ } catch (e) {
1347
+ tunnelHealth.ok = false;
1348
+ tunnelHealth.reason = e.message;
1349
+ }
1350
+ } else {
1351
+ tunnelHealth.ok = false;
1352
+ tunnelHealth.reason = tunnelError || (tunnelProc ? "URL not yet available" : "Not running");
1353
+ }
1354
+ results["__tunnel__"] = tunnelHealth;
1355
+
882
1356
  return ok(res, { results });
883
1357
  } catch (err) {
884
1358
  return strike(res, 502, err.message);
885
1359
  }
886
1360
  }
887
1361
 
1362
+ // GET /tunnel/test — end-to-end tunnel health check (hits /ping through the tunnel)
1363
+ if (method === "GET" && reqPath === "/tunnel/test") {
1364
+ if (lockedGuard(res)) return;
1365
+ if (!tunnelUrl || !tunnelUrl.startsWith("http")) {
1366
+ return ok(res, { ok: false, reason: "Tunnel not connected" });
1367
+ }
1368
+ const start = Date.now();
1369
+ try {
1370
+ // Hit /ping through the public tunnel URL — proves full roundtrip
1371
+ const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(8000) });
1372
+ const data = await resp.json();
1373
+ const latencyMs = Date.now() - start;
1374
+ if (data.status === "ok") {
1375
+ // Also verify SSE endpoint is reachable
1376
+ const sseResp = await fetch(tunnelUrl + "/sse", { signal: AbortSignal.timeout(5000) });
1377
+ const sseOk = sseResp.headers.get("content-type")?.includes("text/event-stream");
1378
+ sseResp.body?.cancel?.();
1379
+ return ok(res, {
1380
+ ok: true,
1381
+ latencyMs,
1382
+ tunnelUrl,
1383
+ sseUrl: tunnelUrl + "/sse",
1384
+ sseReachable: !!sseOk,
1385
+ pid: data.pid,
1386
+ });
1387
+ }
1388
+ return ok(res, { ok: false, reason: "Ping returned unexpected response", data });
1389
+ } catch (e) {
1390
+ return ok(res, { ok: false, reason: e.message, latencyMs: Date.now() - start });
1391
+ }
1392
+ }
1393
+
888
1394
  // POST /change-pw — change master password (must be unlocked)
889
1395
  if (method === "POST" && reqPath === "/change-pw") {
890
1396
  if (lockedGuard(res)) return;
@@ -1066,6 +1572,8 @@ async function actionStart(opts) {
1066
1572
  console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
1067
1573
  }
1068
1574
  console.log(chalk.gray(` Stop: clauth serve stop\n`));
1575
+ // Auto-open browser
1576
+ openBrowser(`http://127.0.0.1:${info.port}`);
1069
1577
  } else {
1070
1578
  console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
1071
1579
  process.exit(1);
@@ -1176,6 +1684,8 @@ async function actionForeground(opts) {
1176
1684
  console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
1177
1685
  if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
1178
1686
  console.log(chalk.gray(" Ctrl+C to stop\n"));
1687
+ // Auto-open browser
1688
+ openBrowser(`http://127.0.0.1:${port}`);
1179
1689
  });
1180
1690
 
1181
1691
  server.on("error", err => {
@@ -1200,7 +1710,7 @@ async function actionForeground(opts) {
1200
1710
  // Secrets are delivered via temp files — never in the MCP response.
1201
1711
 
1202
1712
  import { createInterface } from "readline";
1203
- import { execSync } from "child_process";
1713
+ import { execSync, spawn as spawnProc } from "child_process";
1204
1714
 
1205
1715
  const ENV_MAP = {
1206
1716
  "github": "GITHUB_TOKEN",