@lifeaitools/clauth 2.1.1 → 2.10.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.
Files changed (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2618 -11238
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +86 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -0,0 +1,123 @@
1
+ // cli/http/components/dashboard-component.js
2
+ // The Dashboard (Site/Website) component: GET / -- the built-in web
3
+ // dashboard's HTML shell -- plus POST /message, the legacy SSE-transport
4
+ // JSON-RPC endpoint a GET /sse session's client POSTs its calls to (the
5
+ // Streamable-HTTP transport's own POST handling lives in the MCP-transport
6
+ // component; this is only the older two-leg SSE-session sibling).
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+ import { fileURLToPath } from "node:url";
11
+ import { readBody } from "../request-utils.js";
12
+ import { MCP_TOOLS, filterMcpToolsForWriteMode } from "../../mcp/tool-schemas.js";
13
+ import { mcpError } from "../../mcp/tool-registry.js";
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.json"), "utf8"));
17
+ const VERSION = pkg.version;
18
+
19
+ const WRITE_TOKEN_BYTES = 32;
20
+ const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
21
+ function makeWriteToken() {
22
+ return {
23
+ token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
24
+ expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
25
+ };
26
+ }
27
+
28
+ function sseSend(sessionRes, event, data) {
29
+ sessionRes.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
30
+ }
31
+
32
+ export function registerDashboardRoutes(ctx) {
33
+ // POST /message?sessionId=xxx — JSON-RPC over SSE
34
+ ctx.registerWriteRoute("POST", "/message", async (req, res, url) => {
35
+ const sessionId = url.searchParams.get("sessionId");
36
+ const session = sessionId ? ctx.sseSessions.get(sessionId) : null;
37
+
38
+ if (!session) {
39
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
40
+ return res.end(JSON.stringify({ error: "Unknown or expired session" }));
41
+ }
42
+
43
+ let body;
44
+ try { body = await readBody(req); } catch {
45
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
46
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
47
+ }
48
+
49
+ const id = body.id;
50
+ const rpcMethod = body.method;
51
+
52
+ // Acknowledge the POST immediately
53
+ res.writeHead(202, { "Content-Type": "application/json", ...ctx.CORS });
54
+ res.end(JSON.stringify({ ok: true }));
55
+
56
+ // Handle JSON-RPC methods
57
+ if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
58
+ session.initialized = true;
59
+ return;
60
+ }
61
+
62
+ if (rpcMethod === "initialize") {
63
+ sseSend(session.res, "message", {
64
+ jsonrpc: "2.0", id,
65
+ result: {
66
+ protocolVersion: "2024-11-05",
67
+ serverInfo: { name: "clauth", version: VERSION },
68
+ capabilities: { tools: {} }
69
+ }
70
+ });
71
+ return;
72
+ }
73
+
74
+ if (rpcMethod === "tools/list") {
75
+ sseSend(session.res, "message", {
76
+ jsonrpc: "2.0", id,
77
+ result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
78
+ });
79
+ return;
80
+ }
81
+
82
+ if (rpcMethod === "tools/call") {
83
+ const { name, arguments: args } = body.params || {};
84
+ const vault = ctx.sseVault();
85
+ try {
86
+ const result = await ctx.handleMcpTool(vault, name, args || {});
87
+ // Sync vault mutations back (e.g. unlock sets password)
88
+ ctx.password = vault.password;
89
+ sseSend(session.res, "message", { jsonrpc: "2.0", id, result });
90
+ } catch (err) {
91
+ sseSend(session.res, "message", {
92
+ jsonrpc: "2.0", id,
93
+ result: mcpError(`Internal error: ${err.message}`)
94
+ });
95
+ }
96
+ return;
97
+ }
98
+
99
+ // Unknown method
100
+ sseSend(session.res, "message", {
101
+ jsonrpc: "2.0", id,
102
+ error: { code: -32601, message: `Unknown method: ${rpcMethod}` }
103
+ });
104
+ return;
105
+ });
106
+
107
+ // GET / — built-in web dashboard
108
+ ctx.registerWriteRoute("GET", "/", async (req, res) => {
109
+ // When unlocked, ensure a non-expired write session exists and hand its
110
+ // token to the page so the dashboard is write-ready on load — fixes the
111
+ // --pw/boot.key auto-unlock case where the page never saw the unlock
112
+ // screen and so held no write token. (Consistent with the existing trust
113
+ // model: /v/<service> already serves raw credentials to localhost when
114
+ // unlocked, so a same-origin write token is no broader.)
115
+ let initWriteToken = null;
116
+ if (ctx.password) {
117
+ if (!ctx.writeSession || Date.now() > ctx.writeSession.expiresAt) ctx.writeSession = makeWriteToken();
118
+ initWriteToken = ctx.writeSession.token;
119
+ }
120
+ res.writeHead(200, { "Content-Type": "text/html", ...ctx.CORS });
121
+ return res.end(ctx.dashboardHtml(ctx.port, ctx.whitelist, ctx.isStaged, initWriteToken));
122
+ });
123
+ }
@@ -0,0 +1,41 @@
1
+ // cli/http/components/inbox-component.js
2
+ // The Inbox component: GET /inbox/:sessionKey/peek -- convergence layer 5
3
+ // messaging probe. Returns { pending, sources, max_priority } from
4
+ // collab.inbox_messages via the public.inbox_peek Supabase RPC. An unlocked
5
+ // daemon reads Supabase creds from its own vault; a locked isolated TEST
6
+ // daemon borrows them from the upstream live daemon (CLAUTH_UPSTREAM,
7
+ // default :52437) so regression harnesses need no manual unlock.
8
+ import { deriveToken } from "../../fingerprint.js";
9
+ import * as api from "../../api.js";
10
+
11
+ export function registerInboxRoutes(ctx) {
12
+ ctx.registerPatternRoute("GET", /^\/inbox\/([^/]+)\/peek$/, async (req, res, match) => {
13
+ const sessionKey = decodeURIComponent(match[1]);
14
+ try {
15
+ let key = null;
16
+ let ref = null;
17
+ if (ctx.password) {
18
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
19
+ const svc = await api.retrieve(ctx.password, ctx.machineHash, token, timestamp, "supabase-service");
20
+ const dbRaw = await api.retrieve(ctx.password, ctx.machineHash, token, timestamp, "supabase-db");
21
+ if (svc.error || dbRaw.error) return ctx.noBrowserJson(res, 503, { error: svc.error || dbRaw.error });
22
+ key = svc.value;
23
+ try { ref = JSON.parse(dbRaw.value).ref; } catch { /* below */ }
24
+ } else {
25
+ const up = process.env.CLAUTH_UPSTREAM || "http://127.0.0.1:52437";
26
+ key = (await (await fetch(`${up}/v/supabase-service`)).text()).trim();
27
+ try { ref = JSON.parse(await (await fetch(`${up}/v/supabase-db`)).text()).ref; } catch { /* below */ }
28
+ }
29
+ if (!key || !ref) return ctx.noBrowserJson(res, 503, { error: "supabase creds unavailable" });
30
+ const resp = await fetch(`https://${ref}.supabase.co/rest/v1/rpc/inbox_peek`, {
31
+ method: "POST",
32
+ headers: { apikey: key, Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
33
+ body: JSON.stringify({ p_session_key: sessionKey }),
34
+ });
35
+ if (!resp.ok) return ctx.noBrowserJson(res, 502, { error: `supabase rpc ${resp.status}` });
36
+ return ctx.noBrowserJson(res, 200, await resp.json());
37
+ } catch (err) {
38
+ return ctx.noBrowserJson(res, 503, { error: err.message });
39
+ }
40
+ });
41
+ }
@@ -0,0 +1,275 @@
1
+ // cli/http/components/mcp-transport-component.js
2
+ // The MCP-transport component: OAuth 2.1 discovery (.well-known/* -- RFC 9728
3
+ // + RFC 8414), the Bearer-token gate that fronts every MCP endpoint, the
4
+ // Streamable HTTP JSON-RPC transport (POST /sse, /mcp, /gws, /clauth, /fs),
5
+ // and the legacy SSE session-open handshake (GET /sse and namespaced paths).
6
+ // This is the live transport claude.ai's Custom Connector and every local
7
+ // MCP client actually talk to -- the most sensitive extraction this session,
8
+ // handled last and verified against the isolated instance only.
9
+ //
10
+ // noAuthHost/localTrustedHost/requestHost are derived fresh from req.headers.host
11
+ // on every request -- pure per-request logic, duplicated locally rather than
12
+ // passed via ctx (there's nothing closure-scoped about them). MCP_PATHS/
13
+ // toolsForPath/serverNameForPath are likewise pure and self-contained.
14
+ //
15
+ // oauthBase, sseVault, handleMcpTool stay defined in serve.js as closure
16
+ // functions and are passed via ctx as references, matching every other
17
+ // extraction this session (dashboard-component.js's dashboardHtml/sseVault/
18
+ // handleMcpTool trio is the direct precedent). oauthTokens (a Set, mutated
19
+ // only via .has()) and sseSessions (a Map, mutated via .set()/.delete()) are
20
+ // passed by reference -- no accessor wrapping needed. password needs a
21
+ // setter (tools/call syncs vault mutations back) and sseCounter needs both
22
+ // getter and setter since it's incremented in place (ctx.sseCounter++ invokes
23
+ // both transparently, same as any other JS accessor pair).
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import os from "node:os";
27
+ import { fileURLToPath } from "node:url";
28
+ import { readBody } from "../request-utils.js";
29
+ import { MCP_TOOLS, filterMcpToolsForWriteMode } from "../../mcp/tool-schemas.js";
30
+ import { mcpError } from "../../mcp/tool-registry.js";
31
+
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.json"), "utf8"));
34
+ const VERSION = pkg.version;
35
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
36
+
37
+ // Hosts that bypass OAuth (fresh domains for claude.ai compatibility) —
38
+ // those domains use the tunnel URL itself as a shared secret (like regen-media).
39
+ const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
40
+ const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs"];
41
+
42
+ function hostFlags(req) {
43
+ const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
44
+ return {
45
+ noAuthHost: NOAUTH_HOSTS.includes(requestHost),
46
+ localTrustedHost: ["127.0.0.1", "localhost", "::1", "[::1]"].includes(requestHost),
47
+ };
48
+ }
49
+
50
+ function toolsForPath(p) {
51
+ const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
52
+ if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
53
+ if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "call_agent" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
54
+ if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
55
+ return tools; // /mcp — all tools
56
+ }
57
+ function serverNameForPath(p) {
58
+ if (p === "/gws") return "gws";
59
+ if (p === "/clauth") return "clauth";
60
+ if (p === "/fs") return "fs";
61
+ return "clauth";
62
+ }
63
+
64
+ export function registerMcpTransportRoutes(ctx) {
65
+ // ── OAuth Discovery (RFC 9728 + RFC 8414) — any /.well-known/* path ──
66
+ ctx.registerPatternRoute("GET", /^\/\.well-known\//, async (req, res, match, url) => {
67
+ const reqPath = url.pathname;
68
+ const { noAuthHost } = hostFlags(req);
69
+
70
+ // Suppress OAuth discovery on noauth hosts — prevents claude.ai from entering OAuth flow
71
+ if (noAuthHost) {
72
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
73
+ return res.end(JSON.stringify({ error: "not_found" }));
74
+ }
75
+
76
+ // Restore full well-known + OAuth so custom setup with client_id/secret works.
77
+ if (reqPath.startsWith("/.well-known/oauth-protected-resource")) {
78
+ const base = ctx.oauthBase();
79
+ const suffix = reqPath.replace("/.well-known/oauth-protected-resource", "").replace(/^\//, "");
80
+ const resourcePath = suffix && ["/gws", "/clauth", "/mcp", "/fs", "/sse"].includes("/" + suffix) ? "/" + suffix : "/sse";
81
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
82
+ return res.end(JSON.stringify({
83
+ resource: `${base}${resourcePath}`,
84
+ authorization_servers: [base],
85
+ scopes_supported: ["mcp:tools"],
86
+ bearer_methods_supported: ["header"],
87
+ }));
88
+ }
89
+
90
+ if (reqPath === "/.well-known/oauth-authorization-server") {
91
+ const base = ctx.oauthBase();
92
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
93
+ return res.end(JSON.stringify({
94
+ issuer: base,
95
+ authorization_endpoint: `${base}/authorize`,
96
+ token_endpoint: `${base}/token`,
97
+ registration_endpoint: `${base}/register`,
98
+ response_types_supported: ["code"],
99
+ grant_types_supported: ["authorization_code"],
100
+ token_endpoint_auth_methods_supported: ["none"],
101
+ code_challenge_methods_supported: ["S256"],
102
+ scopes_supported: ["mcp:tools"],
103
+ }));
104
+ }
105
+
106
+ // No known well-known sub-path matched — same generic 404 shape as the
107
+ // real terminal fallback in serve.js, since this pattern-route's regex
108
+ // has already claimed every /.well-known/* request.
109
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] 404 GET ${reqPath}\n`); } catch {}
110
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
111
+ return res.end(JSON.stringify({ error: `Unknown endpoint: ${reqPath}` }));
112
+ });
113
+
114
+ // ── MCP endpoint auth (401 gate) + Streamable HTTP transport + SSE open ──
115
+ // Registered once per literal path (POST and GET) rather than a pattern
116
+ // route — these are five fixed strings, not a dynamic segment.
117
+ const ALL_MCP_POST_PATHS = ["/sse", ...MCP_PATHS];
118
+ for (const reqPath of ALL_MCP_POST_PATHS) {
119
+ ctx.registerWriteRoute("POST", reqPath, (req, res, url) => handleMcpPost(ctx, req, res, url, reqPath));
120
+ ctx.registerWriteRoute("GET", reqPath, (req, res, url) => handleMcpGet(ctx, req, res, url, reqPath));
121
+ }
122
+ }
123
+
124
+ async function handleMcpPost(ctx, req, res, url, reqPath) {
125
+ const isMcpPath = MCP_PATHS.includes(reqPath);
126
+ const { noAuthHost, localTrustedHost } = hostFlags(req);
127
+
128
+ // ── MCP endpoint auth — 401 gate (OAuth 2.1 protocol) ──
129
+ // Skipped for noauth hosts — those domains use tunnel URL as shared secret (like regen-media)
130
+ const authHeader = req.headers.authorization;
131
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
132
+
133
+ if (!noAuthHost && !localTrustedHost && (!token || !ctx.oauthTokens.has(token))) {
134
+ // No valid Bearer token → return 401 with discovery hint
135
+ const base = ctx.oauthBase();
136
+ const resourcePath = isMcpPath ? reqPath.slice(1) : "sse"; // "mcp", "gws", "clauth", or "sse"
137
+ const resourceMeta = `${base}/.well-known/oauth-protected-resource/${resourcePath}`;
138
+ res.writeHead(401, {
139
+ "Content-Type": "application/json",
140
+ "WWW-Authenticate": `Bearer realm="MCP", resource_metadata="${resourceMeta}"`,
141
+ "Cache-Control": "no-store",
142
+ ...ctx.CORS,
143
+ });
144
+ return res.end(JSON.stringify({
145
+ error: "unauthorized",
146
+ error_description: "Bearer token required",
147
+ }));
148
+ }
149
+
150
+ // Valid token or trusted localhost — mark remote status and fall through to MCP handling
151
+ req._clauthRemote = !localTrustedHost;
152
+
153
+ // ── MCP Streamable HTTP transport ──
154
+ // claude.ai requires text/event-stream SSE format (like regen-media/Express).
155
+ let body;
156
+ try { body = await readBody(req); } catch {
157
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
158
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
159
+ }
160
+
161
+ const id = body.id;
162
+ const rpcMethod = body.method;
163
+ const logMsg = `[${new Date().toISOString()}] Streamable HTTP [${reqPath}]: ${rpcMethod} id=${id}\n`;
164
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
165
+
166
+ // Check if client accepts SSE — claude.ai sends Accept: application/json, text/event-stream
167
+ const acceptsSSE = (req.headers.accept || "").includes("text/event-stream");
168
+
169
+ // Helper: respond in SSE or JSON format based on client preference
170
+ // SSE response matches regen-media/Express exactly: no CORS, Content-Length, x-powered-by
171
+ function mcpRespond(res, jsonRpcResponse) {
172
+ if (acceptsSSE) {
173
+ const ssePayload = `event: message\ndata: ${JSON.stringify(jsonRpcResponse)}\n\n`;
174
+ const buf = Buffer.from(ssePayload, "utf8");
175
+ res.writeHead(200, {
176
+ "Content-Type": "text/event-stream",
177
+ "Content-Length": buf.length,
178
+ "Cache-Control": "no-cache",
179
+ "vary": "Accept-Encoding",
180
+ "x-powered-by": "Express",
181
+ });
182
+ return res.end(buf);
183
+ }
184
+ res.writeHead(200, { "Content-Type": "application/json", ...ctx.CORS });
185
+ return res.end(JSON.stringify(jsonRpcResponse));
186
+ }
187
+
188
+ // Notifications — no response needed
189
+ if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
190
+ res.writeHead(202, ctx.CORS);
191
+ return res.end();
192
+ }
193
+
194
+ if (rpcMethod === "initialize") {
195
+ const result = {
196
+ protocolVersion: "2025-03-26",
197
+ serverInfo: { name: serverNameForPath(reqPath), version: VERSION },
198
+ capabilities: { tools: { listChanged: true } }
199
+ };
200
+ return mcpRespond(res, { jsonrpc: "2.0", id, result });
201
+ }
202
+
203
+ if (rpcMethod === "tools/list") {
204
+ return mcpRespond(res, { jsonrpc: "2.0", id, result: { tools: toolsForPath(reqPath) } });
205
+ }
206
+
207
+ if (rpcMethod === "tools/call") {
208
+ const { name, arguments: args } = body.params || {};
209
+ const vault = ctx.sseVault();
210
+ vault.remote = !!req._clauthRemote;
211
+ try {
212
+ const result = await ctx.handleMcpTool(vault, name, args || {});
213
+ ctx.password = vault.password;
214
+ return mcpRespond(res, { jsonrpc: "2.0", id, result });
215
+ } catch (err) {
216
+ return mcpRespond(res, { jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) });
217
+ }
218
+ }
219
+
220
+ // Unknown method
221
+ return mcpRespond(res, { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${rpcMethod}` } });
222
+ }
223
+
224
+ function handleMcpGet(ctx, req, res, url, reqPath) {
225
+ const { noAuthHost } = hostFlags(req);
226
+
227
+ // ── MCP SSE transport — /sse and namespaced paths ────
228
+ // Remote clients (claude.ai) arrive with a Bearer token via OAuth.
229
+ // They use Streamable HTTP (POST only) — return 405 on GET so claude.ai
230
+ // knows to POST directly. Local clients have no Bearer token and use SSE.
231
+ const isMcpPath = MCP_PATHS.includes(reqPath);
232
+ if (isMcpPath) {
233
+ const getAuthHeader = req.headers.authorization;
234
+ const getToken = getAuthHeader?.startsWith("Bearer ") ? getAuthHeader.slice(7) : null;
235
+ if (!noAuthHost && getToken && ctx.oauthTokens.has(getToken)) {
236
+ res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST", ...ctx.CORS });
237
+ return res.end(JSON.stringify({ error: "Method Not Allowed", detail: "Use POST for Streamable HTTP transport" }));
238
+ }
239
+ }
240
+
241
+ // GET /sse|/clauth — open SSE stream, receive endpoint event
242
+ const sessionId = `ses_${++ctx.sseCounter}_${Date.now()}`;
243
+
244
+ res.writeHead(200, {
245
+ "Content-Type": "text/event-stream",
246
+ "Cache-Control": "no-cache",
247
+ "Connection": "keep-alive",
248
+ ...ctx.CORS,
249
+ });
250
+
251
+ ctx.sseSessions.set(sessionId, { res, initialized: false });
252
+
253
+ // Send the endpoint URI the client should POST to
254
+ // Use absolute URL when tunnel is active so remote clients can resolve it
255
+ const basePath = ctx.tunnelUrl || `http://127.0.0.1:${ctx.port}`;
256
+ const endpoint = `${basePath}/message?sessionId=${sessionId}`;
257
+ res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
258
+
259
+ // Keepalive every 15s
260
+ const keepalive = setInterval(() => {
261
+ try { res.write(": keepalive\n\n"); } catch {}
262
+ }, 15_000);
263
+
264
+ // Cleanup on disconnect
265
+ req.on("close", () => {
266
+ clearInterval(keepalive);
267
+ ctx.sseSessions.delete(sessionId);
268
+ const logLine = `[${new Date().toISOString()}] SSE session closed: ${sessionId}\n`;
269
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
270
+ });
271
+
272
+ const logLine = `[${new Date().toISOString()}] SSE session opened: ${sessionId}\n`;
273
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
274
+ return;
275
+ }
@@ -0,0 +1,207 @@
1
+ // cli/http/components/oauth-component.js
2
+ // The OAuth component: OAuth 2.1 dynamic client registration
3
+ // (POST /register), the auto-approving authorization endpoint (GET
4
+ // /authorize -- PKCE mandatory, no user interaction since clauth is a
5
+ // personal vault), and the token endpoint (POST /token -- exchanges an
6
+ // authorization code + PKCE verifier for a bearer access token). This
7
+ // is the auth flow claude.ai's Custom Connector uses to reach the MCP
8
+ // transport, distinct from the vault password gate.
9
+ //
10
+ // /authorize is a GET that MUTATES state (issues a one-time code into
11
+ // oauthCodes) -- registered via ctx.registerWriteRoute despite the verb,
12
+ // same "mutation decides the registry, not the HTTP method" principle
13
+ // used for /tunnel/setup-state earlier this session.
14
+ //
15
+ // oauthClients/oauthCodes/oauthTokens are Map/Set instances that stay
16
+ // defined in serve.js and are passed via ctx as plain object
17
+ // references -- calling .get()/.set()/.delete()/.add() on the SAME
18
+ // instance needs no accessor wrapping, unlike a primitive "let" that
19
+ // gets reassigned (contrast with vault-auth-component.js's password/
20
+ // writeSession get/set pairs). saveClients/saveTokens (persist-to-disk)
21
+ // stay in serve.js too, passed as plain function references.
22
+ import crypto from "node:crypto";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import os from "node:os";
26
+ import { operation } from "../../supervisor-registry.js";
27
+ import { readBody, readRawBody } from "../request-utils.js";
28
+
29
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
30
+
31
+ // Hosts that bypass OAuth entirely (fresh domains for claude.ai compatibility
32
+ // -- those domains use the tunnel URL itself as a shared secret, like
33
+ // regen-media). Dynamic Client Registration (RFC 7591) must not be reachable
34
+ // on them. This check used to live as a raw if-block in serve.js's if-chain,
35
+ // but the exact-match write-route Map (where these 3 routes now live) is
36
+ // checked BEFORE that if-chain -- so the block never actually ran once these
37
+ // routes moved to the registry, silently exposing /register, /authorize, and
38
+ // /token on the noauth hosts. Restored here, at the point the Map actually
39
+ // intercepts the request.
40
+ const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
41
+ function isNoAuthHost(req) {
42
+ const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
43
+ return NOAUTH_HOSTS.includes(requestHost);
44
+ }
45
+
46
+ function sha256base64url(str) {
47
+ return crypto.createHash("sha256").update(str).digest("base64url");
48
+ }
49
+
50
+ export function registerOAuthRoutes(ctx) {
51
+ ctx.registerWriteRoute("POST", "/register", async (req, res, url) => {
52
+ if (isNoAuthHost(req)) {
53
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
54
+ return res.end(JSON.stringify({ error: "not_found" }));
55
+ }
56
+ let body;
57
+ try { body = await readBody(req); } catch {
58
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
59
+ return res.end(JSON.stringify({ error: "invalid_request" }));
60
+ }
61
+ const clientId = crypto.randomBytes(16).toString("hex");
62
+ const client = {
63
+ client_id: clientId,
64
+ // NO client_secret — public client (OAuth 2.1)
65
+ client_name: body.client_name || "unknown",
66
+ redirect_uris: body.redirect_uris || [],
67
+ grant_types: body.grant_types || ["authorization_code"],
68
+ response_types: body.response_types || ["code"],
69
+ token_endpoint_auth_method: "none", // PUBLIC CLIENT
70
+ };
71
+ ctx.oauthClients.set(clientId, client);
72
+ ctx.saveClients(ctx.oauthClients);
73
+ const logMsg = `[${new Date().toISOString()}] OAuth: registered public client ${clientId} (${client.client_name})\n`;
74
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
75
+ operation("oauth.client_register", { client_id: clientId, client_name: client.client_name }, null, { ok: true });
76
+ res.writeHead(201, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
77
+ return res.end(JSON.stringify(client));
78
+ });
79
+ ctx.registerWriteRoute("GET", "/authorize", async (req, res, url) => {
80
+ if (isNoAuthHost(req)) {
81
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
82
+ return res.end(JSON.stringify({ error: "not_found" }));
83
+ }
84
+ const clientId = url.searchParams.get("client_id");
85
+ const redirectUri = url.searchParams.get("redirect_uri");
86
+ const state = url.searchParams.get("state");
87
+ const codeChallenge = url.searchParams.get("code_challenge");
88
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
89
+
90
+ // Validate required params
91
+ if (!clientId || !redirectUri) {
92
+ res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
93
+ return res.end("Missing client_id or redirect_uri");
94
+ }
95
+
96
+ // PKCE is MANDATORY — reject if missing
97
+ if (!codeChallenge || codeChallengeMethod !== "S256") {
98
+ res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
99
+ return res.end("PKCE required: code_challenge with S256 method");
100
+ }
101
+
102
+ // Validate client exists
103
+ if (!ctx.oauthClients.has(clientId)) {
104
+ res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
105
+ return res.end("Unknown client_id");
106
+ }
107
+
108
+ // Validate redirect_uri matches registered URIs
109
+ const client = ctx.oauthClients.get(clientId);
110
+ if (client.redirect_uris.length > 0 && !client.redirect_uris.includes(redirectUri)) {
111
+ res.writeHead(400, { "Content-Type": "text/plain", ...ctx.CORS });
112
+ return res.end("redirect_uri mismatch");
113
+ }
114
+
115
+ // Auto-approve (no user interaction — clauth is a personal vault)
116
+ const code = crypto.randomBytes(32).toString("hex");
117
+ ctx.oauthCodes.set(code, {
118
+ client_id: clientId,
119
+ redirect_uri: redirectUri,
120
+ code_challenge: codeChallenge,
121
+ expires: Date.now() + 300_000, // 5 minutes
122
+ });
123
+
124
+ const redirect = new URL(redirectUri);
125
+ redirect.searchParams.set("code", code);
126
+ if (state) redirect.searchParams.set("state", state);
127
+
128
+ const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code for ${clientId}, redirect to ${redirect.origin}\n`;
129
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
130
+ res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store", ...ctx.CORS });
131
+ return res.end();
132
+ });
133
+ ctx.registerWriteRoute("POST", "/token", async (req, res, url) => {
134
+ if (isNoAuthHost(req)) {
135
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
136
+ return res.end(JSON.stringify({ error: "not_found" }));
137
+ }
138
+ let body;
139
+ const ct = req.headers["content-type"] || "";
140
+ try {
141
+ if (ct.includes("application/json")) {
142
+ body = await readBody(req);
143
+ } else {
144
+ // application/x-www-form-urlencoded (Claude uses this)
145
+ const raw = await readRawBody(req);
146
+ body = Object.fromEntries(new URLSearchParams(raw));
147
+ }
148
+ } catch {
149
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
150
+ return res.end(JSON.stringify({ error: "invalid_request" }));
151
+ }
152
+
153
+ // Validate grant_type
154
+ if (body.grant_type !== "authorization_code") {
155
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
156
+ return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
157
+ }
158
+
159
+ // Validate authorization code exists and is not expired
160
+ const stored = ctx.oauthCodes.get(body.code);
161
+ if (!stored || stored.expires < Date.now()) {
162
+ ctx.oauthCodes.delete(body.code);
163
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
164
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "Code expired or invalid" }));
165
+ }
166
+
167
+ // Validate client_id matches
168
+ if (body.client_id !== stored.client_id) {
169
+ ctx.oauthCodes.delete(body.code);
170
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
171
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "client_id mismatch" }));
172
+ }
173
+
174
+ // Validate redirect_uri matches
175
+ if (body.redirect_uri !== stored.redirect_uri) {
176
+ ctx.oauthCodes.delete(body.code);
177
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
178
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "redirect_uri mismatch" }));
179
+ }
180
+
181
+ // PKCE verification — MANDATORY (not optional)
182
+ if (!body.code_verifier) {
183
+ ctx.oauthCodes.delete(body.code);
184
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
185
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier required" }));
186
+ }
187
+
188
+ const computed = sha256base64url(body.code_verifier);
189
+ if (computed !== stored.code_challenge) {
190
+ ctx.oauthCodes.delete(body.code);
191
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
192
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
193
+ }
194
+
195
+ // All checks passed — delete code (one-time use) and issue token
196
+ ctx.oauthCodes.delete(body.code);
197
+ const accessToken = crypto.randomBytes(32).toString("hex");
198
+ ctx.oauthTokens.add(accessToken);
199
+ ctx.saveTokens(ctx.oauthTokens);
200
+
201
+ const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
202
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
203
+ operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
204
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
205
+ return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
206
+ });
207
+ }