@lifeaitools/clauth 2.10.1 → 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.
- package/.clauth-skill/references/operator-guide.md +2 -12
- package/cli/commands/serve.js +204 -15
- package/cli/http/components/callagent-terminal-component.js +5 -5
- package/cli/http/components/mcp-transport-component.js +10 -17
- package/cli/http/components/oauth-component.js +74 -40
- package/cli/http/components/supervisor-component.js +18 -14
- package/cli/http/components/test-static-oauth-component.js +233 -0
- package/cli/index.js +9 -17
- package/cli/services/file-io.js +21 -3
- package/cli/services/file-io.test.js +33 -1
- package/cli/supervisor-registry.js +644 -59
- package/cli/supervisor-registry.test.js +568 -28
- package/cli/watchdog-registry.js +9 -0
- package/package.json +2 -1
- 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 +38 -49
- package/supabase/migrations/004_remove_audit_and_rate_limiting.sql +29 -0
|
@@ -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:
|
package/cli/commands/serve.js
CHANGED
|
@@ -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
|
|
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
|
|
613
|
-
//
|
|
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
|
-
|
|
619
|
-
|
|
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 {
|
|
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(
|
|
1195
|
-
try { fs.writeFileSync(TOKENS_FILE, JSON.stringify([...
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 (!
|
|
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 (
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
|
|
116
|
-
const
|
|
117
|
-
ctx.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
}
|