@lifeaitools/clauth 2.15.2 → 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/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
|
@@ -28,32 +28,31 @@ import { readBody, readRawBody } from "../request-utils.js";
|
|
|
28
28
|
|
|
29
29
|
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
return
|
|
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
44
|
}
|
|
45
45
|
|
|
46
|
-
function
|
|
47
|
-
|
|
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;
|
|
46
|
+
function sha256base64url(str) {
|
|
47
|
+
return crypto.createHash("sha256").update(str).digest("base64url");
|
|
53
48
|
}
|
|
54
49
|
|
|
55
50
|
export function registerOAuthRoutes(ctx) {
|
|
56
51
|
ctx.registerWriteRoute("POST", "/register", async (req, res, url) => {
|
|
52
|
+
if (isNoAuthHost(req)) {
|
|
53
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
54
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
55
|
+
}
|
|
57
56
|
let body;
|
|
58
57
|
try { body = await readBody(req); } catch {
|
|
59
58
|
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
@@ -78,6 +77,10 @@ export function registerOAuthRoutes(ctx) {
|
|
|
78
77
|
return res.end(JSON.stringify(client));
|
|
79
78
|
});
|
|
80
79
|
ctx.registerWriteRoute("GET", "/authorize", async (req, res, url) => {
|
|
80
|
+
if (isNoAuthHost(req)) {
|
|
81
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
82
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
83
|
+
}
|
|
81
84
|
const clientId = url.searchParams.get("client_id");
|
|
82
85
|
const redirectUri = url.searchParams.get("redirect_uri");
|
|
83
86
|
const state = url.searchParams.get("state");
|
|
@@ -109,66 +112,29 @@ export function registerOAuthRoutes(ctx) {
|
|
|
109
112
|
return res.end("redirect_uri mismatch");
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
ctx.
|
|
115
|
+
// Auto-approve (no user interaction — clauth is a personal vault)
|
|
116
|
+
const code = crypto.randomBytes(32).toString("hex");
|
|
117
|
+
ctx.oauthCodes.set(code, {
|
|
115
118
|
client_id: clientId,
|
|
116
119
|
redirect_uri: redirectUri,
|
|
117
120
|
code_challenge: codeChallenge,
|
|
118
|
-
|
|
119
|
-
scopes,
|
|
120
|
-
machine_id: ctx.machineHash,
|
|
121
|
-
expires: Date.now() + 300_000,
|
|
121
|
+
expires: Date.now() + 300_000, // 5 minutes
|
|
122
122
|
});
|
|
123
|
-
|
|
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`;
|
|
124
129
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
125
|
-
res.writeHead(
|
|
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" });
|
|
130
|
+
res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store", ...ctx.CORS });
|
|
169
131
|
return res.end();
|
|
170
132
|
});
|
|
171
133
|
ctx.registerWriteRoute("POST", "/token", async (req, res, url) => {
|
|
134
|
+
if (isNoAuthHost(req)) {
|
|
135
|
+
res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS });
|
|
136
|
+
return res.end(JSON.stringify({ error: "not_found" }));
|
|
137
|
+
}
|
|
172
138
|
let body;
|
|
173
139
|
const ct = req.headers["content-type"] || "";
|
|
174
140
|
try {
|
|
@@ -229,13 +195,13 @@ export function registerOAuthRoutes(ctx) {
|
|
|
229
195
|
// All checks passed — delete code (one-time use) and issue token
|
|
230
196
|
ctx.oauthCodes.delete(body.code);
|
|
231
197
|
const accessToken = crypto.randomBytes(32).toString("hex");
|
|
232
|
-
ctx.oauthTokens.
|
|
198
|
+
ctx.oauthTokens.add(accessToken);
|
|
233
199
|
ctx.saveTokens(ctx.oauthTokens);
|
|
234
200
|
|
|
235
201
|
const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
|
|
236
202
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
237
203
|
operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
|
|
238
204
|
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...ctx.CORS });
|
|
239
|
-
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope:
|
|
205
|
+
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
|
|
240
206
|
});
|
|
241
207
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli/http/components/supervisor-component.js
|
|
2
|
-
// The Supervisor-write component: plugin rescan/test/
|
|
3
|
-
// health probe + surface actions, and tunnel-route
|
|
4
|
-
// hasSupervisorWrite() (the supervisor's own
|
|
2
|
+
// The Supervisor-write component: plugin rescan/enable/disable/test/
|
|
3
|
+
// promote, surface health probe + surface actions, and tunnel-route
|
|
4
|
+
// CRUD -- all gated by hasSupervisorWrite() (the supervisor's own
|
|
5
5
|
// write-token, a THIRD distinct guard from lockedGuard/writeGuard and
|
|
6
6
|
// from the ops plane's bearer token), except /v1/surfaces/health which
|
|
7
7
|
// is deliberately guard-free (read-only health sweep, run whenever the
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// every other component this session.
|
|
17
17
|
import {
|
|
18
18
|
discoverPlugins,
|
|
19
|
+
setPluginEnabled,
|
|
19
20
|
runPluginAction,
|
|
20
21
|
probeAllSurfaceHealth,
|
|
21
22
|
runSurfaceAction,
|
|
@@ -40,17 +41,16 @@ export function registerSupervisorRoutes(ctx) {
|
|
|
40
41
|
return ctx.ok(res, discoverPlugins());
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// starting/stopping a surface always required a separate, unrelated
|
|
46
|
-
// action (runSurfaceAction "start"/"restart"). That disconnect made the
|
|
47
|
-
// flag actively misleading rather than a real lifecycle control.
|
|
48
|
-
const pluginActionMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(test|promote)$/);
|
|
49
|
-
if (method === "POST" && pluginActionMatch) {
|
|
44
|
+
const pluginEnableMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable|test|promote)$/);
|
|
45
|
+
if (method === "POST" && pluginEnableMatch) {
|
|
50
46
|
if (!ctx.hasSupervisorWrite(req)) return ctx.rejectSupervisorWrite(res);
|
|
51
|
-
const pluginId = decodeURIComponent(
|
|
52
|
-
const op =
|
|
53
|
-
const result =
|
|
47
|
+
const pluginId = decodeURIComponent(pluginEnableMatch[1]);
|
|
48
|
+
const op = pluginEnableMatch[2];
|
|
49
|
+
const result = op === "enable"
|
|
50
|
+
? setPluginEnabled(pluginId, true)
|
|
51
|
+
: op === "disable"
|
|
52
|
+
? setPluginEnabled(pluginId, false)
|
|
53
|
+
: runPluginAction(pluginId, op);
|
|
54
54
|
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...ctx.CORS });
|
|
55
55
|
return res.end(JSON.stringify(result));
|
|
56
56
|
}
|
package/cli/services/file-io.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// git-exec.js/fs-upload-sessions.js — these are file-content concerns, not
|
|
5
5
|
// mount-resolution concerns.
|
|
6
6
|
|
|
7
|
-
import fsSync from "node:fs";
|
|
8
7
|
import path from "node:path";
|
|
9
8
|
import crypto from "node:crypto";
|
|
10
9
|
import { mkdir, writeFile, rename, stat, readFile } from "node:fs/promises";
|
|
@@ -13,33 +12,16 @@ export function sha256Hex(value) {
|
|
|
13
12
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
function
|
|
17
|
-
|
|
15
|
+
export async function atomicWriteText(filePath, content) {
|
|
16
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
17
|
+
const tempPath = path.join(
|
|
18
18
|
path.dirname(filePath),
|
|
19
19
|
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
20
20
|
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function atomicWriteText(filePath, content) {
|
|
24
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
25
|
-
const tempPath = atomicTempPath(filePath);
|
|
26
21
|
await writeFile(tempPath, content, "utf8");
|
|
27
22
|
await rename(tempPath, filePath);
|
|
28
23
|
}
|
|
29
24
|
|
|
30
|
-
// Sync twin of atomicWriteText -- same mkdir+temp+write+rename contract, for
|
|
31
|
-
// callers that can't be async (e.g. cli/supervisor-registry.js's mutators,
|
|
32
|
-
// all currently synchronous). Identified as a needed pairing rather than a
|
|
33
|
-
// fourth independent reimplementation of this pattern (also hand-rolled in
|
|
34
|
-
// cli/ops/job-store.js and standalone/fs-mcp/lib/webdav-config.js) by a peer
|
|
35
|
-
// review of the commit that first needed it.
|
|
36
|
-
export function atomicWriteTextSync(filePath, content) {
|
|
37
|
-
fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
38
|
-
const tempPath = atomicTempPath(filePath);
|
|
39
|
-
fsSync.writeFileSync(tempPath, content, "utf8");
|
|
40
|
-
fsSync.renameSync(tempPath, filePath);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
25
|
export async function fileInfo(filePath, requestedPath) {
|
|
44
26
|
const s = await stat(filePath);
|
|
45
27
|
const info = {
|
|
@@ -1,42 +1,10 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
3
|
|
|
7
|
-
import { sha256Hex
|
|
4
|
+
import { sha256Hex } from "./file-io.js";
|
|
8
5
|
|
|
9
6
|
test("sha256Hex hashes deterministically", () => {
|
|
10
7
|
assert.equal(sha256Hex("hello"), sha256Hex("hello"));
|
|
11
8
|
assert.notEqual(sha256Hex("hello"), sha256Hex("world"));
|
|
12
9
|
assert.match(sha256Hex("hello"), /^[0-9a-f]{64}$/);
|
|
13
10
|
});
|
|
14
|
-
|
|
15
|
-
// atomicWriteTextSync is the sync twin of atomicWriteText, added so
|
|
16
|
-
// cli/supervisor-registry.js's (synchronous) writeJson could reuse this
|
|
17
|
-
// module's atomic-write contract instead of hand-rolling a fourth
|
|
18
|
-
// independent copy of it (peer-review finding, 2026-09-02).
|
|
19
|
-
test("atomicWriteTextSync creates the target file with the given content, creating parent dirs as needed", () => {
|
|
20
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-atomic-write-"));
|
|
21
|
-
try {
|
|
22
|
-
const target = path.join(root, "nested", "dir", "file.txt");
|
|
23
|
-
atomicWriteTextSync(target, "hello world");
|
|
24
|
-
assert.equal(fs.readFileSync(target, "utf8"), "hello world");
|
|
25
|
-
} finally {
|
|
26
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("atomicWriteTextSync replaces existing content and leaves no temp file behind", () => {
|
|
31
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-atomic-write-"));
|
|
32
|
-
try {
|
|
33
|
-
const target = path.join(root, "file.txt");
|
|
34
|
-
atomicWriteTextSync(target, "first");
|
|
35
|
-
atomicWriteTextSync(target, "second");
|
|
36
|
-
assert.equal(fs.readFileSync(target, "utf8"), "second");
|
|
37
|
-
const leftovers = fs.readdirSync(root).filter((name) => name !== "file.txt");
|
|
38
|
-
assert.deepEqual(leftovers, [], `expected no stray temp files, found: ${JSON.stringify(leftovers)}`);
|
|
39
|
-
} finally {
|
|
40
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
41
|
-
}
|
|
42
|
-
});
|