@lifeaitools/clauth 2.10.2 → 2.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,9 +36,10 @@ supabase db push
36
36
  This creates:
37
37
  - `clauth_services` — service registry (12 services seeded)
38
38
  - `clauth_machines` — machine fingerprint registry
39
- - `clauth_audit` — all operations logged
40
39
  - Vault helper RPCs (upsert/decrypt/delete/list)
41
40
 
41
+ Also run `004_remove_audit_and_rate_limiting.sql` after the above (or `supabase db push`, which applies all migrations in order). `001_clauth_schema.sql` still creates a `clauth_audit` table as part of its original, unedited history, but the current `auth-vault` function (v3+) neither reads nor writes it — it was removed 2026-09-03 after an unindexed per-request COUNT query against it cascaded into a full project outage. Machine lockout (`clauth_machines.fail_count` / `.locked`, 5 failed attempts) is the operative protection now; see 004's own comment for the full incident writeup.
42
+
42
43
  ---
43
44
 
44
45
  ## Step 3 — Deploy Edge Function
@@ -127,17 +128,6 @@ than invoking PM2 directly.
127
128
 
128
129
  ---
129
130
 
130
- ## Viewing the Audit Log
131
-
132
- ```sql
133
- select machine_hash, service_name, action, result, detail, created_at
134
- from clauth_audit
135
- order by created_at desc
136
- limit 50;
137
- ```
138
-
139
- ---
140
-
141
131
  ## Disabling a Machine
142
132
 
143
133
  If a machine is lost or stolen:
@@ -29,6 +29,7 @@ import { registerDashboardRoutes } from "../http/components/dashboard-component.
29
29
  import { registerWatchdogRoutes } from "../http/components/watchdog-component.js";
30
30
  import { registerInboxRoutes } from "../http/components/inbox-component.js";
31
31
  import { registerMcpTransportRoutes } from "../http/components/mcp-transport-component.js";
32
+ import { registerTestStaticOAuthRoutes } from "../http/components/test-static-oauth-component.js"; // TEST-ONLY: additive, see file header
32
33
  import { readBody } from "../http/request-utils.js";
33
34
  import chalk from "chalk";
34
35
  import ora from "ora";
@@ -43,6 +44,9 @@ import { loadConfig as webdavLoadConfig, addMount as webdavAddMount, removeMount
43
44
  import {
44
45
  discoverPlugins,
45
46
  getSupervisorDir,
47
+ isGenuinelyIsolatedInstance,
48
+ isProcessAlive,
49
+ resolveIsolatedSupervisorDir,
46
50
  getSupervisorPort,
47
51
  loadSupervisorState,
48
52
  listPlugins,
@@ -56,7 +60,6 @@ import {
56
60
  probeAllSurfaceHealth,
57
61
  runPluginAction,
58
62
  runSurfaceAction,
59
- setPluginEnabled,
60
63
  supervisorHealth,
61
64
  operation,
62
65
  } from "../supervisor-registry.js";
@@ -601,7 +604,11 @@ const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
601
604
  const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
602
605
  const SUPERVISOR_PID_FILE = path.join(os.tmpdir(), "clauth-supervisor.pid");
603
606
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
604
- const MAX_LOG_LINES = 80;
607
+ const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000;
608
+ // Hard backstop only — age-based trimming below is what actually governs
609
+ // retention. This just prevents unbounded growth if something goes wrong
610
+ // with timestamp parsing (e.g. a burst of non-timestamped lines).
611
+ const MAX_LOG_LINES = 200_000;
605
612
 
606
613
  // LOG_FILE is a plain append-only text log shared by every clauth instance on
607
614
  // this machine (live, staged, isolated-test all resolve the same os.tmpdir()
@@ -609,14 +616,41 @@ const MAX_LOG_LINES = 80;
609
616
  // to it per-request, with no central writer to retrofit a cap into. Trimming
610
617
  // it here instead, on a timer, keeps every append call site untouched. A
611
618
  // benign race between two instances trimming at once just means both
612
- // converge to roughly the last MAX_LOG_LINES lines — harmless for a plain
613
- // text tail, unlike a JSON store.
619
+ // converge to roughly the same retained window — harmless for a plain text
620
+ // tail, unlike a JSON store.
621
+ //
622
+ // Age-based (24h), not line-count-based: a fixed line cap (was 80) gets
623
+ // exceeded by routine dashboard polling alone within under a minute, which
624
+ // silently evicted real evidence twice in one debugging session (2026-08-31)
625
+ // — once losing the only record of why the vault hard-locked, once losing
626
+ // freshly-generated OAuth test credentials before they could be read. Only
627
+ // rewrites the file when there's actually something to drop, so tail -f
628
+ // doesn't see spurious "file truncated" resets on every trim tick.
614
629
  function trimLogFile() {
615
630
  try {
616
631
  const content = fs.readFileSync(LOG_FILE, "utf8");
617
632
  const lines = content.split("\n");
618
- if (lines.length > MAX_LOG_LINES) {
619
- fs.writeFileSync(LOG_FILE, lines.slice(-MAX_LOG_LINES).join("\n"), "utf8");
633
+ const cutoff = Date.now() - MAX_LOG_AGE_MS;
634
+ let dropCount = 0;
635
+ // An undated line (e.g. a wrapped stack trace) inherits the age
636
+ // classification of the most recent dated line rather than hard-stopping
637
+ // the scan. Breaking on the first undated line is what got this stuck:
638
+ // once a trim ends on an undated line, that line becomes lines[0] on the
639
+ // next pass, the loop breaks on iteration 1 with dropCount=0 forever, and
640
+ // age-based trimming silently stops working until the line-count backstop
641
+ // eventually kicks in.
642
+ let dropping = true;
643
+ for (const line of lines) {
644
+ const match = line.match(/^\[(.+?)\]/);
645
+ const ts = match ? Date.parse(match[1]) : NaN;
646
+ if (!Number.isNaN(ts)) dropping = ts < cutoff;
647
+ if (dropping) dropCount++;
648
+ else break; // first fresh dated line ends the drop run
649
+ }
650
+ const overLineCap = Math.max(0, lines.length - MAX_LOG_LINES);
651
+ const trimCount = Math.max(dropCount, overLineCap);
652
+ if (trimCount > 0) {
653
+ fs.writeFileSync(LOG_FILE, lines.slice(trimCount).join("\n"), "utf8");
620
654
  }
621
655
  } catch {}
622
656
  }
@@ -789,10 +823,6 @@ function removeSupervisorPid() {
789
823
  try { fs.unlinkSync(SUPERVISOR_PID_FILE); } catch {}
790
824
  }
791
825
 
792
- function isProcessAlive(pid) {
793
- try { process.kill(pid, 0); return true; } catch { return false; }
794
- }
795
-
796
826
  // STOP-ON-REJECTION classifier. A server `reason` of invalid_token / machine_locked
797
827
  // (and friends) is a terminal verdict: the supplied password/machine is wrong or the
798
828
  // machine is locked, so every further attempt only burns another of the 5 server-side
@@ -1078,6 +1108,18 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
1078
1108
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1079
1109
  "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, mcp-session-id",
1080
1110
  };
1111
+ // Chrome's Private Network Access requires this on the PREFLIGHT (OPTIONS)
1112
+ // response before it allows a public-origin page to fetch a loopback
1113
+ // address -- confirmed live 2026-08-31 via Playwright against the real
1114
+ // consent page's attest fetch (127.0.0.1 from https://clauth.regendevcorp.com):
1115
+ // "Permission was denied for this request to access the loopback address
1116
+ // space." The actual GET /authorize/attest response already sent this
1117
+ // header (oauth-component.js), but the blanket OPTIONS handler used to bake
1118
+ // it into the shared CORS object -- answering every preflight in the app,
1119
+ // not just this one route -- which grants PNA approval for every route on
1120
+ // the daemon, not only the one that actually needs it (rdc:review finding,
1121
+ // 2026-08-31). Scoped to just the attest path's own preflight instead.
1122
+ const ATTEST_PREFLIGHT_CORS = { ...CORS, "Access-Control-Allow-Private-Network": "true" };
1081
1123
  const NO_BROWSER_CORS = {
1082
1124
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1083
1125
  "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, mcp-session-id",
@@ -1183,16 +1225,23 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
1183
1225
 
1184
1226
  // ── OAuth provider (self-contained for claude.ai MCP) ──────
1185
1227
  const oauthCodes = new Map(); // code → { client_id, redirect_uri, code_challenge, expires }
1228
+ const oauthApprovals = new Map(); // approval → pending authorization request
1229
+ const oauthMachineAttestationKey = crypto.randomBytes(32); // never leaves this enrolled daemon
1186
1230
 
1187
1231
  // Persist tokens + clients to disk so daemon restarts don't invalidate sessions
1188
1232
  const TOKENS_FILE = path.join(os.tmpdir(), "clauth-oauth-tokens.json");
1189
1233
  const CLIENTS_FILE = path.join(os.tmpdir(), "clauth-oauth-clients.json");
1190
1234
 
1191
1235
  function loadTokens() {
1192
- try { return new Set(JSON.parse(fs.readFileSync(TOKENS_FILE, "utf8"))); } catch { return new Set(); }
1236
+ try {
1237
+ const stored = JSON.parse(fs.readFileSync(TOKENS_FILE, "utf8"));
1238
+ return new Map(Array.isArray(stored) && Array.isArray(stored[0])
1239
+ ? stored
1240
+ : (stored || []).map((token) => [token, { scopes: ["mcp:tools"], expires: 0 }]));
1241
+ } catch { return new Map(); }
1193
1242
  }
1194
- function saveTokens(set) {
1195
- try { fs.writeFileSync(TOKENS_FILE, JSON.stringify([...set])); } catch {}
1243
+ function saveTokens(tokens) {
1244
+ try { fs.writeFileSync(TOKENS_FILE, JSON.stringify([...tokens])); } catch {}
1196
1245
  }
1197
1246
  function loadClients() {
1198
1247
  try { return new Map(JSON.parse(fs.readFileSync(CLIENTS_FILE, "utf8"))); } catch { return new Map(); }
@@ -2193,9 +2242,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2193
2242
  get CORS() { return CORS; },
2194
2243
  oauthClients,
2195
2244
  oauthCodes,
2245
+ oauthApprovals,
2196
2246
  oauthTokens,
2197
2247
  saveClients,
2198
2248
  saveTokens,
2249
+ machineHash,
2250
+ machineAttestationKey: oauthMachineAttestationKey,
2251
+ port,
2199
2252
  });
2200
2253
 
2201
2254
  registerCallAgentTerminalRoutes({
@@ -2255,6 +2308,30 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2255
2308
  noBrowserJson,
2256
2309
  });
2257
2310
 
2311
+ // TEST-ONLY: additive, isolated from everything below — see file header
2312
+ // in test-static-oauth-component.js. Does not read or write any state
2313
+ // this daemon already owns. Registered BEFORE registerMcpTransportRoutes
2314
+ // specifically: patternRoutes matches in registration order (first match
2315
+ // wins, see the loop this file's registerPatternRoute feeds), and
2316
+ // mcp-transport-component.js registers a broad /^\/\.well-known\// catch-
2317
+ // all that would otherwise shadow this component's /.well-known/*/test
2318
+ // discovery routes and 404 them via its own generic fallback (confirmed
2319
+ // live 2026-08-31 — that's what broke claude.ai's discovery the first time).
2320
+ // TEST-ONLY, and gated OFF by default: registerTestStaticOAuthRoutes stands
2321
+ // up a second, permanently-reachable OAuth provider + a bearer-gated MCP
2322
+ // surface. Unconditional registration put it on the same port as the real
2323
+ // production vault with no way to turn it off (rdc:review finding,
2324
+ // 2026-08-31) -- an explicit opt-in keeps it off every real instance,
2325
+ // production included, unless someone deliberately wants it for a manual
2326
+ // OAuth-connector verification pass.
2327
+ if (process.env.CLAUTH_TEST_OAUTH_ENABLE === "1") {
2328
+ registerTestStaticOAuthRoutes({
2329
+ registerWriteRoute,
2330
+ registerPatternRoute,
2331
+ oauthBase,
2332
+ });
2333
+ }
2334
+
2258
2335
  registerMcpTransportRoutes({
2259
2336
  registerWriteRoute,
2260
2337
  registerPatternRoute,
@@ -2475,9 +2552,14 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2475
2552
  const reqPath = url.pathname;
2476
2553
  const method = req.method;
2477
2554
 
2478
- // Log every request
2555
+ // Log every request — console output too, not just the file. The file
2556
+ // log has repeatedly proven unreliable for real-time debugging this
2557
+ // session (trim-timer rewrites, tunnel-vs-local origin mislabeling);
2558
+ // stdout bypasses all of that and is directly visible in whatever
2559
+ // terminal is running the daemon.
2479
2560
  const logLine = `[${new Date().toISOString()}] ${method} ${reqPath} from=${remote} local=${isLocal}\n`;
2480
2561
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
2562
+ console.log(`[REQ] ${method} ${reqPath} from=${remote} local=${isLocal} origin=${req.headers.origin || "-"} ua=${(req.headers["user-agent"] || "-").slice(0, 60)}`);
2481
2563
 
2482
2564
  // Hard reject anything not from loopback
2483
2565
  if (!isLocal) {
@@ -2486,7 +2568,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2486
2568
 
2487
2569
  // CORS preflight
2488
2570
  if (req.method === "OPTIONS") {
2489
- res.writeHead(204, CORS);
2571
+ res.writeHead(204, reqPath === "/authorize/attest" ? ATTEST_PREFLIGHT_CORS : CORS);
2490
2572
  return res.end();
2491
2573
  }
2492
2574
 
@@ -2759,6 +2841,34 @@ async function actionStart(opts) {
2759
2841
  }
2760
2842
  }
2761
2843
 
2844
+ // This __CLAUTH_DAEMON branch is what the actual production daemon child
2845
+ // runs — both a fresh `serve start` and every `/restart` (system-component.js
2846
+ // spawns its child with __CLAUTH_DAEMON=1 set). actionForeground()'s
2847
+ // unconditional discovery call (added because "normal serve start/foreground
2848
+ // boot never ran it") never fires here — this branch returns before ever
2849
+ // reaching actionForeground. Confirmed via a frozen state.json discovered_at
2850
+ // timestamp that did not advance across several successful /restart calls.
2851
+ // Same try/catch-never-crash-boot discipline as actionForeground's copy.
2852
+ let discoveryResult = null;
2853
+ let discoveryError = null;
2854
+ try {
2855
+ discoveryResult = discoverPlugins();
2856
+ } catch (err) {
2857
+ discoveryError = err;
2858
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ⚠ plugin discovery failed at boot: ${err.message}\n`);
2859
+ }
2860
+ try {
2861
+ operation(
2862
+ "daemon.plugin_discovery",
2863
+ { port, isStaged },
2864
+ null,
2865
+ discoveryError
2866
+ ? { ok: false, error: discoveryError.message }
2867
+ : { ok: true, plugin_count: (discoveryResult?.plugins || []).length },
2868
+ "system",
2869
+ );
2870
+ } catch { /* startup logging must never block the daemon from serving */ }
2871
+
2762
2872
  const server = createServer(password, whitelist, port, tunnelHostname, isStaged);
2763
2873
  server.listen(port, "127.0.0.1", () => {
2764
2874
  if (isStaged) {
@@ -2985,6 +3095,27 @@ async function actionTest(opts) {
2985
3095
  async function actionForeground(opts) {
2986
3096
  const port = parseInt(opts.port || "52437", 10);
2987
3097
  const isolated = !!opts.isolated;
3098
+
3099
+ // rdc:review finding (2026-09-02), 4th round: withStateLock's acquire can
3100
+ // now genuinely throw (5s contention timeout, or a real fs error) from
3101
+ // deep inside an awaited request-handler call chain (e.g. `await
3102
+ // runSurfacePromotion(...)` in cli/http/components/supervisor-component.js).
3103
+ // The main HTTP listener (createServer's request callback, below) has no
3104
+ // try/catch anywhere between it and that await, and this daemon registered
3105
+ // no unhandledRejection handler -- Node's default behavior for an unhandled
3106
+ // rejection (since Node 15, and this package requires Node >=18) is to
3107
+ // terminate the process. A single contended lock could crash the whole
3108
+ // daemon, reachable even from unauthenticated routes. Not a new class of
3109
+ // exposure -- ANY unhandled rejection anywhere in this file already had
3110
+ // this failure mode (tracked as its own follow-up, work item c308a2d0, for
3111
+ // the full "wrap every request handler" fix) -- but this diff's lock is a
3112
+ // new, concrete, reachable trigger for it, so it gets its own guard here
3113
+ // rather than waiting on that broader item. Logs and keeps the daemon
3114
+ // alive instead of a silent, unexplained exit.
3115
+ process.on("unhandledRejection", (reason) => {
3116
+ const msg = `[${new Date().toISOString()}] unhandled rejection (daemon kept alive): ${reason?.stack || reason}\n`;
3117
+ try { fs.appendFileSync(LOG_FILE, msg); } catch { /* logging must never itself crash the daemon */ }
3118
+ });
2988
3119
  const containerPassword = process.env.CLAUTH_MASTER_PASSWORD || process.env["clauth-master-password"] || null;
2989
3120
  const password = isolated ? null : (opts.pw || containerPassword);
2990
3121
  const bindHost = process.env.CLAUTH_BIND_HOST || "127.0.0.1";
@@ -2998,6 +3129,64 @@ async function actionForeground(opts) {
2998
3129
  process.exit(1);
2999
3130
  }
3000
3131
 
3132
+ // rdc:review finding (2026-09-02): a manual `clauth serve foreground
3133
+ // --isolated --port <supervisorPort>` reaches an unauthenticated,
3134
+ // passwordless HTTP server bound to the real supervisor port, operating on
3135
+ // live shared state (isGenuinelyIsolatedInstance below deliberately keeps
3136
+ // the real supervisor port on the live state dir). Tried adding
3137
+ // getSupervisorPort() to the refusal list above, gated behind a new
3138
+ // internalSupervisor marker actionSupervisor() sets for its own automatic
3139
+ // boot -- and broke 39 passing tests in test/serve-http-routes.test.mjs:
3140
+ // that suite's OWN sanctioned pattern (its comment: "the sanctioned
3141
+ // localhost-only test escape hatch... never the live daemon") spawns a
3142
+ // SECOND isolated instance with CLAUTH_SUPERVISOR_PORT deliberately set to
3143
+ // match its own --port, specifically to exercise the write-token-bypass
3144
+ // logic gated on `port === getSupervisorPort()` -- via a raw CLI spawn,
3145
+ // which has no way to set an internal opts field. There is no signal that
3146
+ // distinguishes that sanctioned test invocation from the theoretical
3147
+ // manual-misuse case; both produce identical (isolated:true, port ===
3148
+ // getSupervisorPort()) with no CLAUTH_SUPERVISOR_PORT override reaching
3149
+ // the process before getSupervisorPort() resolves. Left unfixed: reaching
3150
+ // this at all requires local shell access to run the clauth CLI directly,
3151
+ // which already grants the full admin blast radius documented in
3152
+ // .claude/rules/subagent-credentials.md -- an unauthenticated local HTTP
3153
+ // server is not a meaningfully larger grant on top of that. Revisit only
3154
+ // if a real remote/lower-trust invocation path to `--isolated` is ever
3155
+ // added; today all paths to it are local-shell-only.
3156
+
3157
+ // rdc:review finding (2026-09-02): `--isolated` never set CLAUTH_SUPERVISOR_DIR,
3158
+ // so an isolated instance and the live :52437 daemon shared the SAME
3159
+ // state.json by default -- the documented "verify against an isolated
3160
+ // instance first" workflow (.claude/rules/clauth-endpoints.md) did not
3161
+ // actually isolate state. resolveIsolatedSupervisorDir is a pure function
3162
+ // (see supervisor-registry.js) so the decision itself is unit-tested
3163
+ // without spawning a server.
3164
+ //
3165
+ // CRITICAL follow-up finding (2026-09-02, independent review of the fix
3166
+ // above): `opts.isolated` is OVERLOADED. actionSupervisor() unconditionally
3167
+ // sets it to true for an unrelated reason -- skipping vault password auth on
3168
+ // the internal supervisor child process ensureSupervisorStarted() spawns for
3169
+ // every normal `clauth serve start` -- and that child always runs on
3170
+ // getSupervisorPort() (52439 by default), which is NOT in the LIVE_PORT/
3171
+ // STAGED_PORT refusal list above. Without this guard, the first version of
3172
+ // this fix redirected the REAL production supervisor's state.json to an
3173
+ // empty %TEMP% path on every normal boot, silently disabling the entire
3174
+ // health-reconcile/auto-repair loop (gated to exactly this one process --
3175
+ // see `port === getSupervisorPort()` at the setInterval registration below)
3176
+ // with no error and no log line. `serve test`'s own live-verification in the
3177
+ // original fix used TEST_PORT (52440), which never exercises this path --
3178
+ // that is why it didn't catch it.
3179
+ //
3180
+ // CORRECTION (4th review round): the real internal daemon is identified via
3181
+ // __CLAUTH_SUPERVISOR_DAEMON (set by ensureSupervisorStarted, read nowhere
3182
+ // until now), not by port -- see isGenuinelyIsolatedInstance's own comment
3183
+ // for why the port-based version collided with sanctioned test
3184
+ // infrastructure and had to be corrected.
3185
+ const isInternalSupervisorDaemon = process.env.__CLAUTH_SUPERVISOR_DAEMON === "1";
3186
+ if (isGenuinelyIsolatedInstance(isolated, isInternalSupervisorDaemon)) {
3187
+ process.env.CLAUTH_SUPERVISOR_DIR = resolveIsolatedSupervisorDir(port, process.env.CLAUTH_SUPERVISOR_DIR);
3188
+ }
3189
+
3001
3190
  if (password) {
3002
3191
  console.log(chalk.gray("\n Verifying vault credentials..."));
3003
3192
  try {
@@ -37,7 +37,7 @@ import { showTerminalSession } from "../../services/terminal-window.js";
37
37
  import { channelEvents, MAX_CHANNEL_EVENTS } from "../../services/terminal-registry.js";
38
38
  import crypto from "node:crypto";
39
39
 
40
- const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
40
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
41
41
 
42
42
  export function registerCallAgentTerminalRoutes(ctx) {
43
43
  // ── call_agent (Gate B) ───────────────────────────────────────────────────
@@ -46,7 +46,7 @@ export function registerCallAgentTerminalRoutes(ctx) {
46
46
  // async: returns { ok, jobId } immediately; poll GET /call-agent/:jobId.
47
47
  ctx.registerWriteRoute("POST", "/call-agent", async (req, res, url) => {
48
48
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
49
- const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
49
+ const noAuthHost = !LOOPBACK_HOSTS.has(requestHost);
50
50
  // call_agent spawns real `claude` workers. Like legacy /dispatch, it is a
51
51
  // local-tool-only path: reject any browser Origin (CSRF) and, on a noauth
52
52
  // tunnel host (clauth.regendevcorp.com etc.), require a valid
@@ -85,7 +85,7 @@ export function registerCallAgentTerminalRoutes(ctx) {
85
85
  // GET /call-agent/:jobId — poll an async (or completed) call_agent job.
86
86
  ctx.registerPatternRoute("GET", /^\/call-agent\/([^/]+)$/, async (req, res, match, url) => {
87
87
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
88
- const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
88
+ const noAuthHost = !LOOPBACK_HOSTS.has(requestHost);
89
89
  {
90
90
  const expectedToken = await ctx.resolveCallAgentToken();
91
91
  const verdict = ctx.evaluateCallAgentGuard({
@@ -113,7 +113,7 @@ export function registerCallAgentTerminalRoutes(ctx) {
113
113
  // same guard as the poll route above.
114
114
  ctx.registerPatternRoute("POST", /^\/call-agent\/([^/]+)\/(show|hide)$/, async (req, res, match, url) => {
115
115
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
116
- const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
116
+ const noAuthHost = !LOOPBACK_HOSTS.has(requestHost);
117
117
  {
118
118
  const expectedToken = await ctx.resolveCallAgentToken();
119
119
  const verdict = ctx.evaluateCallAgentGuard({
@@ -137,7 +137,7 @@ export function registerCallAgentTerminalRoutes(ctx) {
137
137
  // GET /call-agent — pool stats (health/debug, no credentials).
138
138
  ctx.registerWriteRoute("GET", "/call-agent", async (req, res, url) => {
139
139
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
140
- const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
140
+ const noAuthHost = !LOOPBACK_HOSTS.has(requestHost);
141
141
  {
142
142
  const expectedToken = await ctx.resolveCallAgentToken();
143
143
  const verdict = ctx.evaluateCallAgentGuard({
@@ -34,15 +34,11 @@ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../package.js
34
34
  const VERSION = pkg.version;
35
35
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
36
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
37
  const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs"];
41
38
 
42
39
  function hostFlags(req) {
43
40
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
44
41
  return {
45
- noAuthHost: NOAUTH_HOSTS.includes(requestHost),
46
42
  localTrustedHost: ["127.0.0.1", "localhost", "::1", "[::1]"].includes(requestHost),
47
43
  };
48
44
  }
@@ -60,19 +56,18 @@ function serverNameForPath(p) {
60
56
  if (p === "/fs") return "fs";
61
57
  return "clauth";
62
58
  }
59
+ function validOAuthToken(ctx, token, reqPath) {
60
+ const record = token && ctx.oauthTokens.get(token);
61
+ if (!record || (record.expires && record.expires <= Date.now())) return false;
62
+ const scopes = record.scopes || ["mcp:tools"];
63
+ if (!reqPath || !MCP_PATHS.includes(reqPath)) return scopes.includes("mcp:tools") || scopes.includes("mcp:all");
64
+ return scopes.includes("mcp:tools") || scopes.includes("mcp:all") || scopes.includes(`mcp:${serverNameForPath(reqPath)}`);
65
+ }
63
66
 
64
67
  export function registerMcpTransportRoutes(ctx) {
65
68
  // ── OAuth Discovery (RFC 9728 + RFC 8414) — any /.well-known/* path ──
66
69
  ctx.registerPatternRoute("GET", /^\/\.well-known\//, async (req, res, match, url) => {
67
70
  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
71
  // Restore full well-known + OAuth so custom setup with client_id/secret works.
77
72
  if (reqPath.startsWith("/.well-known/oauth-protected-resource")) {
78
73
  const base = ctx.oauthBase();
@@ -123,14 +118,13 @@ export function registerMcpTransportRoutes(ctx) {
123
118
 
124
119
  async function handleMcpPost(ctx, req, res, url, reqPath) {
125
120
  const isMcpPath = MCP_PATHS.includes(reqPath);
126
- const { noAuthHost, localTrustedHost } = hostFlags(req);
121
+ const { localTrustedHost } = hostFlags(req);
127
122
 
128
123
  // ── 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
124
  const authHeader = req.headers.authorization;
131
125
  const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
132
126
 
133
- if (!noAuthHost && !localTrustedHost && (!token || !ctx.oauthTokens.has(token))) {
127
+ if (!localTrustedHost && !validOAuthToken(ctx, token, reqPath)) {
134
128
  // No valid Bearer token → return 401 with discovery hint
135
129
  const base = ctx.oauthBase();
136
130
  const resourcePath = isMcpPath ? reqPath.slice(1) : "sse"; // "mcp", "gws", "clauth", or "sse"
@@ -222,7 +216,6 @@ async function handleMcpPost(ctx, req, res, url, reqPath) {
222
216
  }
223
217
 
224
218
  function handleMcpGet(ctx, req, res, url, reqPath) {
225
- const { noAuthHost } = hostFlags(req);
226
219
 
227
220
  // ── MCP SSE transport — /sse and namespaced paths ────
228
221
  // Remote clients (claude.ai) arrive with a Bearer token via OAuth.
@@ -232,7 +225,7 @@ function handleMcpGet(ctx, req, res, url, reqPath) {
232
225
  if (isMcpPath) {
233
226
  const getAuthHeader = req.headers.authorization;
234
227
  const getToken = getAuthHeader?.startsWith("Bearer ") ? getAuthHeader.slice(7) : null;
235
- if (!noAuthHost && getToken && ctx.oauthTokens.has(getToken)) {
228
+ if (validOAuthToken(ctx, getToken, reqPath)) {
236
229
  res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST", ...ctx.CORS });
237
230
  return res.end(JSON.stringify({ error: "Method Not Allowed", detail: "Use POST for Streamable HTTP transport" }));
238
231
  }
@@ -28,31 +28,32 @@ import { readBody, readRawBody } from "../request-utils.js";
28
28
 
29
29
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
30
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
31
  function sha256base64url(str) {
47
32
  return crypto.createHash("sha256").update(str).digest("base64url");
48
33
  }
49
34
 
35
+ function approvalPage({ approvalId, clientName, scopes, port }) {
36
+ const escapedName = String(clientName).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
37
+ const scopeList = scopes.map((scope) => `<li>${scope}</li>`).join("") || "<li>mcp:tools</li>";
38
+ return `<!doctype html><html><head><meta charset="utf-8"><title>CLAUTH access request</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:system-ui;margin:3rem;max-width:42rem;color:#18251e}button{padding:.7rem 1rem;margin-right:.6rem;font:inherit;border-radius:.45rem}button[name=decision]{background:#164f35;color:white;border:0}button[value=deny]{background:white;color:#8b1d1d;border:1px solid #8b1d1d}#machine{color:#8b5b00}</style></head><body><h1>CLAUTH access request</h1><p><strong>${escapedName}</strong> is requesting access to this machine's MCP services.</p><p>Requested scopes:</p><ul>${scopeList}</ul><p id="machine">Verifying this browser is paired with the enrolled CLAUTH machine…</p><form method="post" action="/authorize/approve"><input type="hidden" name="approval_id" value="${approvalId}"><input id="attestation" type="hidden" name="attestation"><button id="approve" name="decision" value="approve" type="submit" disabled>Approve access</button><button name="decision" value="deny" type="submit">Deny</button></form><script>fetch('http://127.0.0.1:${port}/authorize/attest?approval_id=${approvalId}').then(r=>r.ok?r.json():Promise.reject()).then(x=>{document.querySelector('#attestation').value=x.attestation;document.querySelector('#approve').disabled=false;document.querySelector('#machine').textContent='This browser is paired with the enrolled CLAUTH machine.'}).catch(()=>{document.querySelector('#machine').textContent='Machine verification failed. Open this request on the machine that runs CLAUTH.'})</script></body></html>`;
39
+ }
40
+
41
+ function attestationFor(ctx, approvalId, nonce) {
42
+ if (!ctx.machineAttestationKey) return null;
43
+ return crypto.createHmac("sha256", ctx.machineAttestationKey).update(`${ctx.machineHash}:${approvalId}:${nonce}`).digest("base64url");
44
+ }
45
+
46
+ function issueAuthorizationCode(ctx, request) {
47
+ const code = crypto.randomBytes(32).toString("hex");
48
+ ctx.oauthCodes.set(code, { ...request, expires: Date.now() + 300_000 });
49
+ const redirect = new URL(request.redirect_uri);
50
+ redirect.searchParams.set("code", code);
51
+ if (request.state) redirect.searchParams.set("state", request.state);
52
+ return redirect;
53
+ }
54
+
50
55
  export function registerOAuthRoutes(ctx) {
51
56
  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
57
  let body;
57
58
  try { body = await readBody(req); } catch {
58
59
  res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
@@ -77,10 +78,6 @@ export function registerOAuthRoutes(ctx) {
77
78
  return res.end(JSON.stringify(client));
78
79
  });
79
80
  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
81
  const clientId = url.searchParams.get("client_id");
85
82
  const redirectUri = url.searchParams.get("redirect_uri");
86
83
  const state = url.searchParams.get("state");
@@ -112,29 +109,66 @@ export function registerOAuthRoutes(ctx) {
112
109
  return res.end("redirect_uri mismatch");
113
110
  }
114
111
 
115
- // Auto-approve (no user interaction — clauth is a personal vault)
116
- const code = crypto.randomBytes(32).toString("hex");
117
- ctx.oauthCodes.set(code, {
112
+ const approvalId = crypto.randomBytes(24).toString("base64url");
113
+ const scopes = (url.searchParams.get("scope") || "mcp:tools").split(/\s+/).filter(Boolean);
114
+ ctx.oauthApprovals.set(approvalId, {
118
115
  client_id: clientId,
119
116
  redirect_uri: redirectUri,
120
117
  code_challenge: codeChallenge,
121
- expires: Date.now() + 300_000, // 5 minutes
118
+ state,
119
+ scopes,
120
+ machine_id: ctx.machineHash,
121
+ expires: Date.now() + 300_000,
122
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`;
123
+ const logMsg = `[${new Date().toISOString()}] OAuth: consent requested for ${clientId}\n`;
129
124
  try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
130
- res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store", ...ctx.CORS });
125
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
126
+ const request = ctx.oauthApprovals.get(approvalId);
127
+ request.nonce = crypto.randomBytes(24).toString("base64url");
128
+ return res.end(approvalPage({ approvalId, clientName: client.client_name, scopes, port: ctx.port }));
129
+ });
130
+ ctx.registerWriteRoute("GET", "/authorize/attest", async (req, res, url) => {
131
+ const host = (req.headers.host || "").split(":")[0].toLowerCase();
132
+ if (!["127.0.0.1", "localhost", "::1", "[::1]"].includes(host)) {
133
+ res.writeHead(403, { "Content-Type": "application/json" });
134
+ return res.end(JSON.stringify({ error: "loopback_required" }));
135
+ }
136
+ const approvalId = url.searchParams.get("approval_id");
137
+ const request = ctx.oauthApprovals.get(approvalId);
138
+ const attestation = request && request.expires > Date.now() ? attestationFor(ctx, approvalId, request.nonce) : null;
139
+ if (!attestation) {
140
+ res.writeHead(400, { "Content-Type": "application/json" });
141
+ return res.end(JSON.stringify({ error: "invalid_or_expired_request" }));
142
+ }
143
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", "Access-Control-Allow-Origin": req.headers.origin || "null", "Access-Control-Allow-Private-Network": "true" });
144
+ return res.end(JSON.stringify({ attestation }));
145
+ });
146
+ ctx.registerWriteRoute("POST", "/authorize/approve", async (req, res) => {
147
+ const raw = await readRawBody(req);
148
+ const body = Object.fromEntries(new URLSearchParams(raw));
149
+ const request = ctx.oauthApprovals.get(body.approval_id);
150
+ ctx.oauthApprovals.delete(body.approval_id);
151
+ if (!request || request.expires < Date.now()) {
152
+ res.writeHead(400, { "Content-Type": "text/plain" });
153
+ return res.end("Authorization request expired or invalid.");
154
+ }
155
+ const expectedAttestation = request && attestationFor(ctx, body.approval_id, request.nonce);
156
+ const presented = Buffer.from(body.attestation || "");
157
+ const expected = Buffer.from(expectedAttestation || "");
158
+ if (!expected.length || presented.length !== expected.length || !crypto.timingSafeEqual(presented, expected)) {
159
+ res.writeHead(403, { "Content-Type": "text/plain" });
160
+ return res.end("Machine verification failed.");
161
+ }
162
+ if (body.decision !== "approve") {
163
+ res.writeHead(403, { "Content-Type": "text/plain" });
164
+ return res.end("Access denied.");
165
+ }
166
+ const redirect = issueAuthorizationCode(ctx, request);
167
+ operation("oauth.consent_approve", { client_id: request.client_id, scopes: request.scopes }, null, { ok: true });
168
+ res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store" });
131
169
  return res.end();
132
170
  });
133
171
  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
172
  let body;
139
173
  const ct = req.headers["content-type"] || "";
140
174
  try {
@@ -195,13 +229,13 @@ export function registerOAuthRoutes(ctx) {
195
229
  // All checks passed — delete code (one-time use) and issue token
196
230
  ctx.oauthCodes.delete(body.code);
197
231
  const accessToken = crypto.randomBytes(32).toString("hex");
198
- ctx.oauthTokens.add(accessToken);
232
+ ctx.oauthTokens.set(accessToken, { scopes: stored.scopes || ["mcp:tools"], machine_id: stored.machine_id, expires: Date.now() + 86_400_000 });
199
233
  ctx.saveTokens(ctx.oauthTokens);
200
234
 
201
235
  const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
202
236
  try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
203
237
  operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
204
238
  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 }));
239
+ return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: (stored.scopes || ["mcp:tools"]).join(" "), expires_in: 86400 }));
206
240
  });
207
241
  }