@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.
- package/.clauth-skill/references/operator-guide.md +12 -2
- package/cli/commands/serve.js +55 -226
- package/cli/http/components/callagent-terminal-component.js +5 -5
- package/cli/http/components/mcp-transport-component.js +17 -10
- package/cli/http/components/oauth-component.js +40 -74
- package/cli/http/components/supervisor-component.js +13 -13
- package/cli/ops/pm2-adapter.js +4 -147
- package/cli/services/file-io.js +3 -21
- package/cli/services/file-io.test.js +1 -33
- package/cli/supervisor-registry.js +59 -454
- package/cli/supervisor-registry.test.js +19 -449
- package/cli/watchdog-registry.js +0 -9
- package/package.json +1 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/functions/auth-vault/index.ts +49 -38
- package/cli/http/components/test-static-oauth-component.js +0 -233
- package/supabase/migrations/004_remove_audit_and_rate_limiting.sql +0 -29
|
@@ -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:
|
package/cli/commands/serve.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
634
|
-
|
|
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(
|
|
1244
|
-
try { fs.writeFileSync(TOKENS_FILE, JSON.stringify([...
|
|
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
|
|
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,
|
|
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
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 && !
|
|
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 (
|
|
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
|
}
|