@lifeaitools/clauth 2.15.3 → 2.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,10 +36,9 @@ 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
39
40
  - Vault helper RPCs (upsert/decrypt/delete/list)
40
41
 
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
-
43
42
  ---
44
43
 
45
44
  ## Step 3 — Deploy Edge Function
@@ -128,6 +127,17 @@ than invoking PM2 directly.
128
127
 
129
128
  ---
130
129
 
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
+
131
141
  ## Disabling a Machine
132
142
 
133
143
  If a machine is lost or stolen:
@@ -29,7 +29,6 @@ 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
33
32
  import { readBody } from "../http/request-utils.js";
34
33
  import chalk from "chalk";
35
34
  import ora from "ora";
@@ -44,9 +43,6 @@ import { loadConfig as webdavLoadConfig, addMount as webdavAddMount, removeMount
44
43
  import {
45
44
  discoverPlugins,
46
45
  getSupervisorDir,
47
- isGenuinelyIsolatedInstance,
48
- isProcessAlive,
49
- resolveIsolatedSupervisorDir,
50
46
  getSupervisorPort,
51
47
  loadSupervisorState,
52
48
  listPlugins,
@@ -60,6 +56,7 @@ import {
60
56
  probeAllSurfaceHealth,
61
57
  runPluginAction,
62
58
  runSurfaceAction,
59
+ setPluginEnabled,
63
60
  supervisorHealth,
64
61
  operation,
65
62
  } from "../supervisor-registry.js";
@@ -604,11 +601,7 @@ const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
604
601
  const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
605
602
  const SUPERVISOR_PID_FILE = path.join(os.tmpdir(), "clauth-supervisor.pid");
606
603
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
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;
604
+ const MAX_LOG_LINES = 80;
612
605
 
613
606
  // LOG_FILE is a plain append-only text log shared by every clauth instance on
614
607
  // this machine (live, staged, isolated-test all resolve the same os.tmpdir()
@@ -616,41 +609,14 @@ const MAX_LOG_LINES = 200_000;
616
609
  // to it per-request, with no central writer to retrofit a cap into. Trimming
617
610
  // it here instead, on a timer, keeps every append call site untouched. A
618
611
  // benign race between two instances trimming at once just means both
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.
612
+ // converge to roughly the last MAX_LOG_LINES lines — harmless for a plain
613
+ // text tail, unlike a JSON store.
629
614
  function trimLogFile() {
630
615
  try {
631
616
  const content = fs.readFileSync(LOG_FILE, "utf8");
632
617
  const lines = content.split("\n");
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");
618
+ if (lines.length > MAX_LOG_LINES) {
619
+ fs.writeFileSync(LOG_FILE, lines.slice(-MAX_LOG_LINES).join("\n"), "utf8");
654
620
  }
655
621
  } catch {}
656
622
  }
@@ -823,6 +789,10 @@ function removeSupervisorPid() {
823
789
  try { fs.unlinkSync(SUPERVISOR_PID_FILE); } catch {}
824
790
  }
825
791
 
792
+ function isProcessAlive(pid) {
793
+ try { process.kill(pid, 0); return true; } catch { return false; }
794
+ }
795
+
826
796
  // STOP-ON-REJECTION classifier. A server `reason` of invalid_token / machine_locked
827
797
  // (and friends) is a terminal verdict: the supplied password/machine is wrong or the
828
798
  // machine is locked, so every further attempt only burns another of the 5 server-side
@@ -1108,18 +1078,6 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
1108
1078
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1109
1079
  "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, mcp-session-id",
1110
1080
  };
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" };
1123
1081
  const NO_BROWSER_CORS = {
1124
1082
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1125
1083
  "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, mcp-session-id",
@@ -1225,23 +1183,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
1225
1183
 
1226
1184
  // ── OAuth provider (self-contained for claude.ai MCP) ──────
1227
1185
  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
1230
1186
 
1231
1187
  // Persist tokens + clients to disk so daemon restarts don't invalidate sessions
1232
1188
  const TOKENS_FILE = path.join(os.tmpdir(), "clauth-oauth-tokens.json");
1233
1189
  const CLIENTS_FILE = path.join(os.tmpdir(), "clauth-oauth-clients.json");
1234
1190
 
1235
1191
  function loadTokens() {
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(); }
1192
+ try { return new Set(JSON.parse(fs.readFileSync(TOKENS_FILE, "utf8"))); } catch { return new Set(); }
1242
1193
  }
1243
- function saveTokens(tokens) {
1244
- try { fs.writeFileSync(TOKENS_FILE, JSON.stringify([...tokens])); } catch {}
1194
+ function saveTokens(set) {
1195
+ try { fs.writeFileSync(TOKENS_FILE, JSON.stringify([...set])); } catch {}
1245
1196
  }
1246
1197
  function loadClients() {
1247
1198
  try { return new Map(JSON.parse(fs.readFileSync(CLIENTS_FILE, "utf8"))); } catch { return new Map(); }
@@ -2242,13 +2193,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2242
2193
  get CORS() { return CORS; },
2243
2194
  oauthClients,
2244
2195
  oauthCodes,
2245
- oauthApprovals,
2246
2196
  oauthTokens,
2247
2197
  saveClients,
2248
2198
  saveTokens,
2249
- machineHash,
2250
- machineAttestationKey: oauthMachineAttestationKey,
2251
- port,
2252
2199
  });
2253
2200
 
2254
2201
  registerCallAgentTerminalRoutes({
@@ -2308,30 +2255,6 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2308
2255
  noBrowserJson,
2309
2256
  });
2310
2257
 
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
-
2335
2258
  registerMcpTransportRoutes({
2336
2259
  registerWriteRoute,
2337
2260
  registerPatternRoute,
@@ -2552,14 +2475,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2552
2475
  const reqPath = url.pathname;
2553
2476
  const method = req.method;
2554
2477
 
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.
2478
+ // Log every request
2560
2479
  const logLine = `[${new Date().toISOString()}] ${method} ${reqPath} from=${remote} local=${isLocal}\n`;
2561
2480
  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)}`);
2563
2481
 
2564
2482
  // Hard reject anything not from loopback
2565
2483
  if (!isLocal) {
@@ -2568,7 +2486,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2568
2486
 
2569
2487
  // CORS preflight
2570
2488
  if (req.method === "OPTIONS") {
2571
- res.writeHead(204, reqPath === "/authorize/attest" ? ATTEST_PREFLIGHT_CORS : CORS);
2489
+ res.writeHead(204, CORS);
2572
2490
  return res.end();
2573
2491
  }
2574
2492
 
@@ -2841,34 +2759,6 @@ async function actionStart(opts) {
2841
2759
  }
2842
2760
  }
2843
2761
 
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
-
2872
2762
  const server = createServer(password, whitelist, port, tunnelHostname, isStaged);
2873
2763
  server.listen(port, "127.0.0.1", () => {
2874
2764
  if (isStaged) {
@@ -3095,27 +2985,6 @@ async function actionTest(opts) {
3095
2985
  async function actionForeground(opts) {
3096
2986
  const port = parseInt(opts.port || "52437", 10);
3097
2987
  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
- });
3119
2988
  const containerPassword = process.env.CLAUTH_MASTER_PASSWORD || process.env["clauth-master-password"] || null;
3120
2989
  const password = isolated ? null : (opts.pw || containerPassword);
3121
2990
  const bindHost = process.env.CLAUTH_BIND_HOST || "127.0.0.1";
@@ -3129,64 +2998,6 @@ async function actionForeground(opts) {
3129
2998
  process.exit(1);
3130
2999
  }
3131
3000
 
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
-
3190
3001
  if (password) {
3191
3002
  console.log(chalk.gray("\n Verifying vault credentials..."));
3192
3003
  try {
@@ -3211,34 +3022,21 @@ async function actionForeground(opts) {
3211
3022
  // so a stale/never-discovered plugin list could sit unnoticed until
3212
3023
  // someone happened to hit rescan. Discovery touching disk must never be
3213
3024
  // allowed to crash daemon boot, hence the try/catch.
3214
- let discoveryResult = null;
3215
- let discoveryError = null;
3216
- try {
3217
- discoveryResult = discoverPlugins();
3218
- } catch (err) {
3219
- discoveryError = err;
3220
- console.log(chalk.yellow(` plugin discovery failed at boot: ${err.message}`));
3221
- }
3222
-
3025
+ // BIND FIRST, DISCOVER SURFACES ON A SEPARATE TICK. Dave, 2026-09-04:
3026
+ // "clauth should be able to respond to a health-ok while it is starting its
3027
+ // surfaces — those should be separate threads." discoverPlugins() is a
3028
+ // synchronous disk scan (readdirSync + manifest hashing) and createOpsServer()
3029
+ // is awaited; running EITHER before server.listen() delays the :52439 bind, so
3030
+ // /health does not answer until they finish. On CARBON7 that pushed the
3031
+ // supervisor past the session guard's 15s /health wait and STOPped the session.
3032
+ // Now the port binds and /health answers immediately; plugins + the ops server
3033
+ // load in the background where a slow/offline discovery cannot block startup.
3223
3034
  const server = createServer(password, whitelist, port, tunnelHostname);
3224
- const opsServer = await createOpsServer();
3225
3035
  const opsPort = port + 4;
3226
- opsServer.listen(opsPort, "127.0.0.1", () => {
3227
- fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ops-server listening on port ${opsPort}\n`);
3228
- });
3229
3036
  server.listen(port, bindHost, () => {
3230
3037
  if (!isolated) writePid(process.pid, port);
3231
3038
  try {
3232
3039
  operation("daemon.started", { port, isolated }, null, { ok: true, version: VERSION }, "system");
3233
- operation(
3234
- "daemon.plugin_discovery",
3235
- { port },
3236
- null,
3237
- discoveryError
3238
- ? { ok: false, error: discoveryError.message }
3239
- : { ok: true, plugin_count: (discoveryResult?.plugins || discoveryResult || []).length },
3240
- "system",
3241
- );
3242
3040
  } catch { /* startup logging must never block the daemon from serving */ }
3243
3041
  console.log(chalk.green(` clauth serve → http://${bindHost}:${port}`));
3244
3042
  if (tunnelHostname) {
@@ -3253,6 +3051,37 @@ async function actionForeground(opts) {
3253
3051
  if (!password && !isolated) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
3254
3052
  console.log(chalk.gray(" Ctrl+C to stop\n"));
3255
3053
  if (!isolated) openBrowser(`http://127.0.0.1:${port}`);
3054
+
3055
+ // Surfaces load AFTER the port is bound and /health answers — separate tick.
3056
+ setImmediate(async () => {
3057
+ let discoveryResult = null;
3058
+ let discoveryError = null;
3059
+ try {
3060
+ discoveryResult = discoverPlugins();
3061
+ } catch (err) {
3062
+ discoveryError = err;
3063
+ console.log(chalk.yellow(` ⚠ plugin discovery failed at boot: ${err.message}`));
3064
+ }
3065
+ try {
3066
+ operation(
3067
+ "daemon.plugin_discovery",
3068
+ { port },
3069
+ null,
3070
+ discoveryError
3071
+ ? { ok: false, error: discoveryError.message }
3072
+ : { ok: true, plugin_count: (discoveryResult?.plugins || discoveryResult || []).length },
3073
+ "system",
3074
+ );
3075
+ } catch { /* startup logging must never block the daemon from serving */ }
3076
+ try {
3077
+ const opsServer = await createOpsServer();
3078
+ opsServer.listen(opsPort, "127.0.0.1", () => {
3079
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ops-server listening on port ${opsPort}\n`);
3080
+ });
3081
+ } catch (err) {
3082
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ops-server failed to start: ${err.message}\n`);
3083
+ }
3084
+ });
3256
3085
  });
3257
3086
 
3258
3087
  server.on("error", err => {
@@ -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 LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
40
+ const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
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 = !LOOPBACK_HOSTS.has(requestHost);
49
+ const noAuthHost = NOAUTH_HOSTS.includes(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 = !LOOPBACK_HOSTS.has(requestHost);
88
+ const noAuthHost = NOAUTH_HOSTS.includes(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 = !LOOPBACK_HOSTS.has(requestHost);
116
+ const noAuthHost = NOAUTH_HOSTS.includes(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 = !LOOPBACK_HOSTS.has(requestHost);
140
+ const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
141
141
  {
142
142
  const expectedToken = await ctx.resolveCallAgentToken();
143
143
  const verdict = ctx.evaluateCallAgentGuard({
@@ -34,11 +34,15 @@ 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"];
37
40
  const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs"];
38
41
 
39
42
  function hostFlags(req) {
40
43
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
41
44
  return {
45
+ noAuthHost: NOAUTH_HOSTS.includes(requestHost),
42
46
  localTrustedHost: ["127.0.0.1", "localhost", "::1", "[::1]"].includes(requestHost),
43
47
  };
44
48
  }
@@ -56,18 +60,19 @@ function serverNameForPath(p) {
56
60
  if (p === "/fs") return "fs";
57
61
  return "clauth";
58
62
  }
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
- }
66
63
 
67
64
  export function registerMcpTransportRoutes(ctx) {
68
65
  // ── OAuth Discovery (RFC 9728 + RFC 8414) — any /.well-known/* path ──
69
66
  ctx.registerPatternRoute("GET", /^\/\.well-known\//, async (req, res, match, url) => {
70
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
+
71
76
  // Restore full well-known + OAuth so custom setup with client_id/secret works.
72
77
  if (reqPath.startsWith("/.well-known/oauth-protected-resource")) {
73
78
  const base = ctx.oauthBase();
@@ -118,13 +123,14 @@ export function registerMcpTransportRoutes(ctx) {
118
123
 
119
124
  async function handleMcpPost(ctx, req, res, url, reqPath) {
120
125
  const isMcpPath = MCP_PATHS.includes(reqPath);
121
- const { localTrustedHost } = hostFlags(req);
126
+ const { noAuthHost, localTrustedHost } = hostFlags(req);
122
127
 
123
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)
124
130
  const authHeader = req.headers.authorization;
125
131
  const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
126
132
 
127
- if (!localTrustedHost && !validOAuthToken(ctx, token, reqPath)) {
133
+ if (!noAuthHost && !localTrustedHost && (!token || !ctx.oauthTokens.has(token))) {
128
134
  // No valid Bearer token → return 401 with discovery hint
129
135
  const base = ctx.oauthBase();
130
136
  const resourcePath = isMcpPath ? reqPath.slice(1) : "sse"; // "mcp", "gws", "clauth", or "sse"
@@ -216,6 +222,7 @@ async function handleMcpPost(ctx, req, res, url, reqPath) {
216
222
  }
217
223
 
218
224
  function handleMcpGet(ctx, req, res, url, reqPath) {
225
+ const { noAuthHost } = hostFlags(req);
219
226
 
220
227
  // ── MCP SSE transport — /sse and namespaced paths ────
221
228
  // Remote clients (claude.ai) arrive with a Bearer token via OAuth.
@@ -225,7 +232,7 @@ function handleMcpGet(ctx, req, res, url, reqPath) {
225
232
  if (isMcpPath) {
226
233
  const getAuthHeader = req.headers.authorization;
227
234
  const getToken = getAuthHeader?.startsWith("Bearer ") ? getAuthHeader.slice(7) : null;
228
- if (validOAuthToken(ctx, getToken, reqPath)) {
235
+ if (!noAuthHost && getToken && ctx.oauthTokens.has(getToken)) {
229
236
  res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST", ...ctx.CORS });
230
237
  return res.end(JSON.stringify({ error: "Method Not Allowed", detail: "Use POST for Streamable HTTP transport" }));
231
238
  }