@lifeaitools/clauth 2.10.2 → 2.15.3

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.
@@ -1,7 +1,7 @@
1
1
  // cli/http/components/supervisor-component.js
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
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
- const pluginEnableMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable|test|promote)$/);
44
- if (method === "POST" && pluginEnableMatch) {
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(pluginEnableMatch[1]);
47
- const op = pluginEnableMatch[2];
48
- const result = op === "enable"
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 result = runSurfaceAction(decodeURIComponent(surfaceActionMatch[1]), body?.action || "reconcile");
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
+ }
@@ -53,6 +53,116 @@ function callbackCall(pm2, method, args = []) {
53
53
  });
54
54
  }
55
55
 
56
+ // PM2's own stop/restart/delete callbacks resolve once the daemon has ISSUED
57
+ // the kill, not once the OS has actually reclaimed the process -- confirmed
58
+ // live 2026-09-04, Windows: a `restart` on a real app left its outgoing
59
+ // child alive and still holding its listen port for 1000+ cycles, and a
60
+ // plain `stop` right before an `npm install -g` over that same app's
61
+ // install directory still hit EBUSY because the "stopped" process was, in
62
+ // fact, not. Every caller of this adapter (the ops CLI, ops HTTP surface,
63
+ // and every cutover procedure built on top of it) inherits that lie unless
64
+ // this file closes it once, here, rather than each caller re-discovering it
65
+ // under pressure.
66
+ function isAlive(pid) {
67
+ if (typeof pid !== "number" || pid <= 0) return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (error) {
72
+ // ESRCH: no such process -- genuinely gone. Anything else (EPERM, etc.)
73
+ // means the OS still has an opinion about this pid, so it isn't safe to
74
+ // call it dead.
75
+ return error.code !== "ESRCH";
76
+ }
77
+ }
78
+
79
+ // pm2's own death check on Windows is also parent-pid-only (God.processIsDead
80
+ // reads pm2_env._tree_pids || [pid], and treekill's taskkill callback reports
81
+ // only the pid it was given) -- so a spawned-child topology like clauth's own
82
+ // next-runner fixture can have its child survive everything pm2 itself
83
+ // thinks killed it. Capture _tree_pids alongside pid so this adapter isn't
84
+ // blind to exactly the shape its own fixtures use.
85
+ function pidsOf(processes) {
86
+ return (Array.isArray(processes) ? processes : [processes])
87
+ .flatMap((entry) => [entry?.pid, ...(entry?.pm2_env?._tree_pids || [])])
88
+ .filter((pid) => typeof pid === "number" && pid > 0);
89
+ }
90
+
91
+ const DEATH_TIMEOUT_MS = 3000;
92
+ const KILL_TIMEOUT_MS = 1000;
93
+
94
+ // describe() resolving with [] IS the normal "no such app" case -- do not
95
+ // mask that behind a try/catch that also swallows a real RPC/transport
96
+ // failure. A caller that can't see whether the old process is still alive
97
+ // must not silently report success; let it fail loudly instead.
98
+ function pidsFor(pm2, target) {
99
+ return callbackCall(pm2, "describe", [target]).then(pidsOf);
100
+ }
101
+
102
+ async function waitForDeath(pids, { pollMs = 150, timeoutMs = DEATH_TIMEOUT_MS } = {}) {
103
+ const deadline = Date.now() + timeoutMs;
104
+ let survivors = pids.filter(isAlive);
105
+ while (survivors.length > 0 && Date.now() < deadline) {
106
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
107
+ survivors = survivors.filter(isAlive);
108
+ }
109
+ return survivors;
110
+ }
111
+
112
+ // Verifies the pids captured BEFORE a stop/restart/reload/delete are
113
+ // actually gone afterward, escalating to SIGKILL (TerminateProcess on
114
+ // Windows, same as POSIX SIGKILL) if PM2's own signal didn't finish the job
115
+ // within the timeout. Only throws if a process survives an explicit SIGKILL,
116
+ // which should not happen -- if it does, the caller needs to know its
117
+ // "stop" silently didn't, rather than proceed as though it did. The thrown
118
+ // error carries the surviving pids as structured fields (not just in the
119
+ // message) so a caller logging/job-recording the failure can act on them
120
+ // even after this operation's own bookkeeping (e.g. a deleted pm2 record)
121
+ // is gone.
122
+ async function verifyTerminated(target, oldPids, operation) {
123
+ if (oldPids.length === 0) return;
124
+ const survivors = await waitForDeath(oldPids);
125
+ if (survivors.length === 0) return;
126
+ console.error(
127
+ `[ops] pm2 ${operation} "${target}" did not stop pid(s) ${survivors.join(", ")} within ${DEATH_TIMEOUT_MS}ms -- force-killing`,
128
+ );
129
+ for (const pid of survivors) {
130
+ try {
131
+ process.kill(pid, "SIGKILL");
132
+ } catch {
133
+ // Already gone between the check and the kill -- fine.
134
+ }
135
+ }
136
+ const stillAlive = await waitForDeath(survivors, { timeoutMs: KILL_TIMEOUT_MS });
137
+ if (stillAlive.length > 0) {
138
+ const error = new Error(
139
+ `pm2 ${operation} "${target}": pid(s) ${stillAlive.join(", ")} survived SIGKILL -- refusing to report success`,
140
+ );
141
+ error.pids = stillAlive;
142
+ error.operation = operation;
143
+ error.target = target;
144
+ error.reason = "survived_sigkill";
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ // restart/reload spawn the replacement BEFORE the pm2 callback this adapter
150
+ // awaits resolves (confirmed against pm2's own God.restartProcessId, and
151
+ // reload falls through to the identical path outside cluster_mode -- which
152
+ // is every app this control plane manages). So by the time verification
153
+ // starts, a genuinely new process can already exist, and on Windows a freed
154
+ // pid can be reallocated to it inside the same poll window this adapter is
155
+ // watching. Re-describing AFTER the operation and only verifying death for
156
+ // pids no longer reported as current tells the two apart: a truly orphaned
157
+ // old pid is never in the fresh describe and still gets killed; a pid pm2
158
+ // still reports as live is never touched, whether that's because pm2
159
+ // genuinely didn't rotate it or because it now belongs to the new process.
160
+ async function survivorsOf(pm2, target, oldPids) {
161
+ if (oldPids.length === 0) return [];
162
+ const currentPids = new Set(await pidsFor(pm2, target));
163
+ return oldPids.filter((pid) => !currentPids.has(pid));
164
+ }
165
+
56
166
  function normalizeProcess(process) {
57
167
  if (!process || typeof process !== "object") return process;
58
168
  return {
@@ -101,10 +211,43 @@ export function createPm2Adapter(pm2) {
101
211
  case "logs": return (await callbackCall(pm2, "describe", [missing(input.target, "target")])).map(normalizeProcess);
102
212
  case "bus": return callbackCall(pm2, "launchBus");
103
213
  case "start": return callbackCall(pm2, "start", [missing(input.script, "script"), input.options || {}]);
104
- case "stop": return callbackCall(pm2, "stop", [missing(input.target, "target")]);
105
- case "restart": return callbackCall(pm2, "restart", [missing(input.target, "target"), input.options || {}]);
106
- case "reload": return callbackCall(pm2, "reload", [missing(input.target, "target"), input.options || {}]);
107
- case "delete": return callbackCall(pm2, "delete", [missing(input.target, "target")]);
214
+ case "stop": {
215
+ const target = missing(input.target, "target");
216
+ const oldPids = await pidsFor(pm2, target);
217
+ const result = await callbackCall(pm2, "stop", [target]);
218
+ await verifyTerminated(target, oldPids, "stop");
219
+ return result;
220
+ }
221
+ case "restart": {
222
+ const target = missing(input.target, "target");
223
+ const oldPids = await pidsFor(pm2, target);
224
+ const result = await callbackCall(pm2, "restart", [target, input.options || {}]);
225
+ await verifyTerminated(target, await survivorsOf(pm2, target, oldPids), "restart");
226
+ return result;
227
+ }
228
+ case "reload": {
229
+ // Fork mode -- every app this control plane manages -- makes
230
+ // reload identical to restart inside pm2 (God.reloadProcessId only
231
+ // takes the cluster branch in cluster_mode; everything else falls
232
+ // straight through to God.restartProcessId). clauth's own deploy
233
+ // cutover (deployment-adapter.js) calls this operation, not
234
+ // "restart" -- so this is the actual path "install then hit
235
+ // restart, pm2 should pick up the new one" travels in production,
236
+ // and it needs the identical guarantee, not a lighter one because
237
+ // the pm2 verb sounds gentler.
238
+ const target = missing(input.target, "target");
239
+ const oldPids = await pidsFor(pm2, target);
240
+ const result = await callbackCall(pm2, "reload", [target, input.options || {}]);
241
+ await verifyTerminated(target, await survivorsOf(pm2, target, oldPids), "reload");
242
+ return result;
243
+ }
244
+ case "delete": {
245
+ const target = missing(input.target, "target");
246
+ const oldPids = await pidsFor(pm2, target);
247
+ const result = await callbackCall(pm2, "delete", [target]);
248
+ await verifyTerminated(target, oldPids, "delete");
249
+ return result;
250
+ }
108
251
  case "scale": return callbackCall(pm2, "scale", [missing(input.target, "target"), missing(input.instances, "instances")]);
109
252
  case "reset": return callbackCall(pm2, "reset", [missing(input.target, "target")]);
110
253
  case "dump": return callbackCall(pm2, "dump");
@@ -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
- export async function atomicWriteText(filePath, content) {
16
- await mkdir(path.dirname(filePath), { recursive: true });
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
+ });