@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli/http/components/supervisor-component.js
|
|
2
|
-
// The Supervisor-write component: plugin rescan/
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// The Supervisor-write component: plugin rescan/test/promote, surface
|
|
3
|
+
// health probe + surface actions, and tunnel-route CRUD -- all gated by
|
|
4
|
+
// 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,10 +16,10 @@
|
|
|
16
16
|
// every other component this session.
|
|
17
17
|
import {
|
|
18
18
|
discoverPlugins,
|
|
19
|
-
setPluginEnabled,
|
|
20
19
|
runPluginAction,
|
|
21
20
|
probeAllSurfaceHealth,
|
|
22
21
|
runSurfaceAction,
|
|
22
|
+
runSurfacePromotion,
|
|
23
23
|
addTunnelRoute,
|
|
24
24
|
removeTunnelRoute,
|
|
25
25
|
supervisorHealth,
|
|
@@ -40,16 +40,17 @@ export function registerSupervisorRoutes(ctx) {
|
|
|
40
40
|
return ctx.ok(res, discoverPlugins());
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// enable/disable were removed 2026-08-31: the flag they set was pure
|
|
44
|
+
// metadata (no process spawn) and never implied a running process --
|
|
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) {
|
|
45
50
|
if (!ctx.hasSupervisorWrite(req)) return ctx.rejectSupervisorWrite(res);
|
|
46
|
-
const pluginId = decodeURIComponent(
|
|
47
|
-
const op =
|
|
48
|
-
const result = op
|
|
49
|
-
? setPluginEnabled(pluginId, true)
|
|
50
|
-
: op === "disable"
|
|
51
|
-
? setPluginEnabled(pluginId, false)
|
|
52
|
-
: runPluginAction(pluginId, op);
|
|
51
|
+
const pluginId = decodeURIComponent(pluginActionMatch[1]);
|
|
52
|
+
const op = pluginActionMatch[2];
|
|
53
|
+
const result = runPluginAction(pluginId, op);
|
|
53
54
|
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...ctx.CORS });
|
|
54
55
|
return res.end(JSON.stringify(result));
|
|
55
56
|
}
|
|
@@ -72,7 +73,10 @@ export function registerSupervisorRoutes(ctx) {
|
|
|
72
73
|
res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
|
|
73
74
|
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
74
75
|
}
|
|
75
|
-
const
|
|
76
|
+
const action = body?.action || "reconcile";
|
|
77
|
+
const result = action === "promote"
|
|
78
|
+
? await runSurfacePromotion(decodeURIComponent(surfaceActionMatch[1]))
|
|
79
|
+
: runSurfaceAction(decodeURIComponent(surfaceActionMatch[1]), action);
|
|
76
80
|
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...ctx.CORS });
|
|
77
81
|
return res.end(JSON.stringify(result));
|
|
78
82
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// cli/http/components/test-static-oauth-component.js
|
|
2
|
+
// TEST-ONLY, entirely additive: a minimal static-client OAuth 2.0 provider
|
|
3
|
+
// (client_id + client_secret, authorization_code + refresh_token grants)
|
|
4
|
+
// fronting one dummy MCP tool at /test/mcp. Built to test claude.ai's
|
|
5
|
+
// BUILT-IN custom-connector OAuth support (manual client_id/secret entry —
|
|
6
|
+
// no Dynamic Client Registration) side-by-side with the existing DCR+PKCE+
|
|
7
|
+
// attestation stack in oauth-component.js / mcp-transport-component.js.
|
|
8
|
+
// This file does not import from, call into, or modify either of those —
|
|
9
|
+
// separate Maps, separate routes, separate discovery paths.
|
|
10
|
+
//
|
|
11
|
+
// Static credentials come from env so nothing is hardcoded/committed. If
|
|
12
|
+
// unset, a random pair is generated at daemon start and logged once — read
|
|
13
|
+
// it from clauth-serve.log to configure the claude.ai connector.
|
|
14
|
+
// CLAUTH_TEST_OAUTH_CLIENT_ID / CLAUTH_TEST_OAUTH_CLIENT_SECRET
|
|
15
|
+
//
|
|
16
|
+
// Access tokens are deliberately short-lived (10 min) so refresh_token
|
|
17
|
+
// behavior is observable within a normal test session rather than needing
|
|
18
|
+
// to wait 24h like the real flow.
|
|
19
|
+
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { readRawBody } from "../request-utils.js";
|
|
25
|
+
|
|
26
|
+
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
27
|
+
const ACCESS_TTL_MS = 10 * 60 * 1000;
|
|
28
|
+
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
function log(msg) {
|
|
31
|
+
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] test-oauth: ${msg}\n`); } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Constant-time string compare, safe for unequal lengths (timingSafeEqual
|
|
35
|
+
// throws rather than returning false when buffers differ in length).
|
|
36
|
+
function safeEqual(a, b) {
|
|
37
|
+
const bufA = Buffer.from(String(a ?? ""), "utf8");
|
|
38
|
+
const bufB = Buffer.from(String(b ?? ""), "utf8");
|
|
39
|
+
if (bufA.length !== bufB.length) return false;
|
|
40
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerTestStaticOAuthRoutes(ctx) {
|
|
44
|
+
const CLIENT_ID = process.env.CLAUTH_TEST_OAUTH_CLIENT_ID || crypto.randomBytes(8).toString("hex");
|
|
45
|
+
const CLIENT_SECRET = process.env.CLAUTH_TEST_OAUTH_CLIENT_SECRET || crypto.randomBytes(24).toString("hex");
|
|
46
|
+
log(`static client ready — client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET}`);
|
|
47
|
+
// No Dynamic Client Registration in this static-client model, so there is
|
|
48
|
+
// no per-client redirect_uris list to validate against by default — the
|
|
49
|
+
// real oauth-component.js has one (client.redirect_uris); this needs its
|
|
50
|
+
// own. Fails closed: unset means no redirect_uri is accepted, not "accept
|
|
51
|
+
// anything", matching this whole surface's opt-in-only posture.
|
|
52
|
+
const ALLOWED_REDIRECT_URIS = (process.env.CLAUTH_TEST_OAUTH_REDIRECT_URIS || "")
|
|
53
|
+
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
|
|
55
|
+
const codes = new Map(); // code -> { redirect_uri, expires }
|
|
56
|
+
const accessTokens = new Map(); // token -> { expires }
|
|
57
|
+
const refreshTokens = new Map(); // token -> { expires }
|
|
58
|
+
|
|
59
|
+
function issueTokenPair() {
|
|
60
|
+
const access_token = crypto.randomBytes(32).toString("hex");
|
|
61
|
+
const refresh_token = crypto.randomBytes(32).toString("hex");
|
|
62
|
+
accessTokens.set(access_token, { expires: Date.now() + ACCESS_TTL_MS });
|
|
63
|
+
refreshTokens.set(refresh_token, { expires: Date.now() + REFRESH_TTL_MS });
|
|
64
|
+
return { access_token, refresh_token };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Discovery ──
|
|
68
|
+
// Namespaced entirely under /test/ — mcp-transport-component.js registers
|
|
69
|
+
// a broad /^\/\.well-known\// catch-all BEFORE this component runs, and
|
|
70
|
+
// registerPatternRoute matches in registration order (first match wins,
|
|
71
|
+
// confirmed serve.js:2523), so a bare /.well-known/... path here would be
|
|
72
|
+
// silently swallowed by that existing route rather than reaching this one.
|
|
73
|
+
ctx.registerPatternRoute("GET", /^\/test\/\.well-known\/oauth-protected-resource$/, async (req, res) => {
|
|
74
|
+
const base = ctx.oauthBase();
|
|
75
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
76
|
+
return res.end(JSON.stringify({
|
|
77
|
+
resource: `${base}/test/mcp`,
|
|
78
|
+
authorization_servers: [`${base}/test`],
|
|
79
|
+
scopes_supported: ["test:tools"],
|
|
80
|
+
bearer_methods_supported: ["header"],
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
function authServerMetadata(base) {
|
|
84
|
+
return {
|
|
85
|
+
issuer: `${base}/test`,
|
|
86
|
+
authorization_endpoint: `${base}/test/authorize`,
|
|
87
|
+
token_endpoint: `${base}/test/token`,
|
|
88
|
+
response_types_supported: ["code"],
|
|
89
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
90
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
91
|
+
code_challenge_methods_supported: ["S256"],
|
|
92
|
+
scopes_supported: ["test:tools"],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function serveAuthServerMetadata(req, res) {
|
|
96
|
+
const base = ctx.oauthBase();
|
|
97
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
98
|
+
return res.end(JSON.stringify(authServerMetadata(base)));
|
|
99
|
+
}
|
|
100
|
+
// Observed via live claude.ai request log (2026-08-31): it tries several
|
|
101
|
+
// conventional discovery URL shapes before falling back to guessing
|
|
102
|
+
// <origin>/authorize directly. RFC 8414's actual spec puts .well-known
|
|
103
|
+
// BEFORE the issuer's path (/.well-known/oauth-authorization-server/test),
|
|
104
|
+
// not after -- that was the original bug here. Serving every shape it was
|
|
105
|
+
// observed trying, rather than guessing which one it "should" use.
|
|
106
|
+
ctx.registerPatternRoute("GET", /^\/\.well-known\/oauth-authorization-server\/test$/, serveAuthServerMetadata);
|
|
107
|
+
ctx.registerPatternRoute("GET", /^\/\.well-known\/openid-configuration\/test$/, serveAuthServerMetadata);
|
|
108
|
+
ctx.registerPatternRoute("GET", /^\/test\/\.well-known\/openid-configuration$/, serveAuthServerMetadata);
|
|
109
|
+
ctx.registerPatternRoute("GET", /^\/test\/\.well-known\/oauth-authorization-server$/, serveAuthServerMetadata);
|
|
110
|
+
|
|
111
|
+
// ── Authorize — static client, auto-approve (test only), PKCE optional
|
|
112
|
+
// since this exists specifically to test claude.ai's own client behavior,
|
|
113
|
+
// not to enforce our own policy on it. ──
|
|
114
|
+
ctx.registerWriteRoute("GET", "/test/authorize", async (req, res, url) => {
|
|
115
|
+
const clientId = url.searchParams.get("client_id");
|
|
116
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
117
|
+
const state = url.searchParams.get("state");
|
|
118
|
+
if (clientId !== CLIENT_ID || !redirectUri || !ALLOWED_REDIRECT_URIS.includes(redirectUri)) {
|
|
119
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
120
|
+
return res.end("invalid client_id or unregistered redirect_uri");
|
|
121
|
+
}
|
|
122
|
+
const code = crypto.randomBytes(24).toString("hex");
|
|
123
|
+
codes.set(code, { redirect_uri: redirectUri, expires: Date.now() + 300_000 });
|
|
124
|
+
const redirect = new URL(redirectUri);
|
|
125
|
+
redirect.searchParams.set("code", code);
|
|
126
|
+
if (state) redirect.searchParams.set("state", state);
|
|
127
|
+
log(`authorize -> code issued for client ${clientId}`);
|
|
128
|
+
res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store" });
|
|
129
|
+
return res.end();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Token — authorization_code AND refresh_token grants ──
|
|
133
|
+
ctx.registerWriteRoute("POST", "/test/token", async (req, res) => {
|
|
134
|
+
const raw = await readRawBody(req);
|
|
135
|
+
const ct = req.headers["content-type"] || "";
|
|
136
|
+
const body = ct.includes("application/json") ? JSON.parse(raw || "{}") : Object.fromEntries(new URLSearchParams(raw));
|
|
137
|
+
|
|
138
|
+
// Client auth: client_secret_post (body) or client_secret_basic (header)
|
|
139
|
+
let clientId = body.client_id, clientSecret = body.client_secret;
|
|
140
|
+
const authHeader = req.headers.authorization;
|
|
141
|
+
if (!clientSecret && authHeader?.startsWith("Basic ")) {
|
|
142
|
+
const [u, p] = Buffer.from(authHeader.slice(6), "base64").toString().split(":");
|
|
143
|
+
clientId = clientId || u; clientSecret = p;
|
|
144
|
+
}
|
|
145
|
+
if (clientId !== CLIENT_ID || !safeEqual(clientSecret, CLIENT_SECRET)) {
|
|
146
|
+
log(`token: client auth failed (client_id=${clientId})`);
|
|
147
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
148
|
+
return res.end(JSON.stringify({ error: "invalid_client" }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (body.grant_type === "authorization_code") {
|
|
152
|
+
const stored = codes.get(body.code);
|
|
153
|
+
codes.delete(body.code);
|
|
154
|
+
if (!stored || stored.expires < Date.now() || stored.redirect_uri !== body.redirect_uri) {
|
|
155
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
156
|
+
return res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
157
|
+
}
|
|
158
|
+
const pair = issueTokenPair();
|
|
159
|
+
log(`token: issued via authorization_code`);
|
|
160
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
161
|
+
return res.end(JSON.stringify({ ...pair, token_type: "Bearer", scope: "test:tools", expires_in: ACCESS_TTL_MS / 1000 }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (body.grant_type === "refresh_token") {
|
|
165
|
+
const stored = refreshTokens.get(body.refresh_token);
|
|
166
|
+
if (!stored || stored.expires < Date.now()) {
|
|
167
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
168
|
+
return res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
169
|
+
}
|
|
170
|
+
refreshTokens.delete(body.refresh_token); // rotate — old refresh token is one-time use
|
|
171
|
+
const pair = issueTokenPair();
|
|
172
|
+
log(`token: rotated via refresh_token`);
|
|
173
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
174
|
+
return res.end(JSON.stringify({ ...pair, token_type: "Bearer", scope: "test:tools", expires_in: ACCESS_TTL_MS / 1000 }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
178
|
+
return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── MCP endpoint — self-contained, one dummy tool, no dependency on the
|
|
182
|
+
// real vault/tool surface. 401 gate mirrors the real transport's shape. ──
|
|
183
|
+
ctx.registerWriteRoute("POST", "/test/mcp", async (req, res) => {
|
|
184
|
+
const authHeader = req.headers.authorization;
|
|
185
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
186
|
+
const stored = token && accessTokens.get(token);
|
|
187
|
+
if (!stored || stored.expires < Date.now()) {
|
|
188
|
+
const base = ctx.oauthBase();
|
|
189
|
+
res.writeHead(401, {
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
"WWW-Authenticate": `Bearer realm="test-MCP", resource_metadata="${base}/test/.well-known/oauth-protected-resource"`,
|
|
192
|
+
"Cache-Control": "no-store",
|
|
193
|
+
});
|
|
194
|
+
return res.end(JSON.stringify({ error: "unauthorized", error_description: "Bearer token required or expired" }));
|
|
195
|
+
}
|
|
196
|
+
let body;
|
|
197
|
+
try { body = JSON.parse(await readRawBody(req)); } catch {
|
|
198
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
199
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
200
|
+
}
|
|
201
|
+
const { id, method, params } = body;
|
|
202
|
+
if (method === "notifications/initialized" || method === "initialized") { res.writeHead(202); return res.end(); }
|
|
203
|
+
if (method === "initialize") {
|
|
204
|
+
return respond(res, req, { jsonrpc: "2.0", id, result: {
|
|
205
|
+
protocolVersion: "2025-03-26",
|
|
206
|
+
serverInfo: { name: "test-mcp", version: "0.1.0" },
|
|
207
|
+
capabilities: { tools: { listChanged: true } },
|
|
208
|
+
} });
|
|
209
|
+
}
|
|
210
|
+
if (method === "tools/list") {
|
|
211
|
+
return respond(res, req, { jsonrpc: "2.0", id, result: { tools: [{
|
|
212
|
+
name: "test_ping",
|
|
213
|
+
description: "Returns a fixed value — used only to confirm the static-client OAuth token grants tool access.",
|
|
214
|
+
inputSchema: { type: "object", properties: {} },
|
|
215
|
+
}] } });
|
|
216
|
+
}
|
|
217
|
+
if (method === "tools/call" && params?.name === "test_ping") {
|
|
218
|
+
log(`tools/call test_ping invoked`);
|
|
219
|
+
return respond(res, req, { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "pong from test-mcp" }] } });
|
|
220
|
+
}
|
|
221
|
+
return respond(res, req, { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${method}` } });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function respond(res, req, payload) {
|
|
225
|
+
if ((req.headers.accept || "").includes("text/event-stream")) {
|
|
226
|
+
const buf = Buffer.from(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, "utf8");
|
|
227
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Content-Length": buf.length, "Cache-Control": "no-cache" });
|
|
228
|
+
return res.end(buf);
|
|
229
|
+
}
|
|
230
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
231
|
+
return res.end(JSON.stringify(payload));
|
|
232
|
+
}
|
|
233
|
+
}
|
package/cli/index.js
CHANGED
|
@@ -150,7 +150,7 @@ import { runInstall } from './commands/install.js';
|
|
|
150
150
|
import { runUninstall } from './commands/uninstall.js';
|
|
151
151
|
import { runScrub } from './commands/scrub.js';
|
|
152
152
|
import { runServe, MCP_TOOLS } from './commands/serve.js';
|
|
153
|
-
import { deregisterPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
|
|
153
|
+
import { deregisterPlugin, isMcpServerPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
|
|
154
154
|
import { runOps } from './commands/ops.js';
|
|
155
155
|
import { runOpsInstall } from './commands/ops-install.js';
|
|
156
156
|
import { runNpm, runPublish } from './commands/npm.js';
|
|
@@ -927,20 +927,6 @@ tunnelCmd
|
|
|
927
927
|
// ──────────────────────────────────────────────
|
|
928
928
|
// clauth mcp list
|
|
929
929
|
// ──────────────────────────────────────────────
|
|
930
|
-
// Known MCP-server plugin ids in the managed fleet. Positive allowlist
|
|
931
|
-
// rather than a naming heuristic (credential-name conventions and health
|
|
932
|
-
// route "kind" vary across these) — dev-center and any future non-MCP
|
|
933
|
-
// pm2-managed surface stay excluded by construction. Deliberately distinct
|
|
934
|
-
// from `clauth list` (vault credential services); this never touches those.
|
|
935
|
-
const MCP_SERVER_PLUGIN_IDS = new Set([
|
|
936
|
-
"fs-mcp",
|
|
937
|
-
"web-research",
|
|
938
|
-
"regen-media",
|
|
939
|
-
"regen-media-local",
|
|
940
|
-
"codeflow-mcp",
|
|
941
|
-
"rdc-skills",
|
|
942
|
-
]);
|
|
943
|
-
|
|
944
930
|
const mcpCmd = program.command("mcp").description("Inspect clauth's own MCP tool catalog and managed MCP-server surfaces");
|
|
945
931
|
|
|
946
932
|
mcpCmd
|
|
@@ -951,7 +937,7 @@ mcpCmd
|
|
|
951
937
|
for (const tool of MCP_TOOLS) {
|
|
952
938
|
console.log(` ${chalk.white(tool.name)} ${chalk.gray(tool.description || "")}`);
|
|
953
939
|
}
|
|
954
|
-
const mcpPlugins = listPlugins().filter(
|
|
940
|
+
const mcpPlugins = listPlugins().filter(isMcpServerPlugin);
|
|
955
941
|
console.log(chalk.cyan(`\n Managed MCP-server surfaces (${mcpPlugins.length}):\n`));
|
|
956
942
|
if (!mcpPlugins.length) {
|
|
957
943
|
console.log(chalk.gray(" none discovered — is the daemon running? clauth serve"));
|
|
@@ -1047,10 +1033,16 @@ pluginCmd
|
|
|
1047
1033
|
.command('deregister <id>')
|
|
1048
1034
|
.description('Remove one managed plugin directory by id and re-run discovery. Removing an unregistered id is a safe no-op.')
|
|
1049
1035
|
.option('--dry-run', 'Resolve and print the directory that would be deleted, without deleting it')
|
|
1036
|
+
.option('--expected-root <path>', 'Refuse unless the registered plugin originated from this package root')
|
|
1037
|
+
.option('--force', 'Recovery only: remove managed metadata even when process cleanup fails; receipt records the bypass')
|
|
1050
1038
|
.action((id, opts) => {
|
|
1051
1039
|
let receipt;
|
|
1052
1040
|
try {
|
|
1053
|
-
receipt = deregisterPlugin(id, 'cli', {
|
|
1041
|
+
receipt = deregisterPlugin(id, 'cli', {
|
|
1042
|
+
dryRun: Boolean(opts.dryRun),
|
|
1043
|
+
expectedRoot: opts.expectedRoot,
|
|
1044
|
+
force: Boolean(opts.force),
|
|
1045
|
+
});
|
|
1054
1046
|
} catch (error) {
|
|
1055
1047
|
console.error(` ✗ deregister_failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1056
1048
|
process.exitCode = 1;
|
package/cli/services/file-io.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import crypto from "node:crypto";
|
|
9
10
|
import { mkdir, writeFile, rename, stat, readFile } from "node:fs/promises";
|
|
@@ -12,16 +13,33 @@ export function sha256Hex(value) {
|
|
|
12
13
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const tempPath = path.join(
|
|
16
|
+
function atomicTempPath(filePath) {
|
|
17
|
+
return 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);
|
|
21
26
|
await writeFile(tempPath, content, "utf8");
|
|
22
27
|
await rename(tempPath, filePath);
|
|
23
28
|
}
|
|
24
29
|
|
|
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
|
+
|
|
25
43
|
export async function fileInfo(filePath, requestedPath) {
|
|
26
44
|
const s = await stat(filePath);
|
|
27
45
|
const info = {
|
|
@@ -1,10 +1,42 @@
|
|
|
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";
|
|
3
6
|
|
|
4
|
-
import { sha256Hex } from "./file-io.js";
|
|
7
|
+
import { sha256Hex, atomicWriteTextSync } from "./file-io.js";
|
|
5
8
|
|
|
6
9
|
test("sha256Hex hashes deterministically", () => {
|
|
7
10
|
assert.equal(sha256Hex("hello"), sha256Hex("hello"));
|
|
8
11
|
assert.notEqual(sha256Hex("hello"), sha256Hex("world"));
|
|
9
12
|
assert.match(sha256Hex("hello"), /^[0-9a-f]{64}$/);
|
|
10
13
|
});
|
|
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
|
+
});
|