@lifeaitools/clauth 2.1.1 → 2.10.1

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.
Files changed (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2618 -11238
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +86 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -0,0 +1,288 @@
1
+ // cli/http/components/service-crud-component.js
2
+ // The Service+WebDAV CRUD component: dynamic-segment vault-credential
3
+ // routes (GET /get/:service and /v/:service -- raw credential reads,
4
+ // POST /rename|/delete|/toggle|/rotate|/set-expiry|/set/:service --
5
+ // mutations) plus DELETE /webdav/mounts/:name. These are the
6
+ // dynamic-segment siblings of the exact-path service routes already
7
+ // migrated earlier this session (/add-service, /update-service,
8
+ // /generate-token, GET /webdav/config, POST /webdav/mounts -- those
9
+ // stay as flat registerReadOnlyRoute/registerWriteRoute calls in
10
+ // serve.js for now, a reasonable interim state since the registry
11
+ // mechanism itself already satisfies "not a raw if-chain").
12
+ //
13
+ // Every dynamic segment here uses ctx.registerPatternRoute, including
14
+ // /rotate/:service and /set-expiry/:service which originally used
15
+ // reqPath.startsWith()/reqPath.slice() instead of a capture regex --
16
+ // rewritten to a /^\/rotate\/(.+)$/-style regex with match[1] instead
17
+ // of a slice (identical semantics: the original slice took everything
18
+ // after the prefix, so does a "(.+)$" capture group), for uniformity
19
+ // with every other route here.
20
+ import { deriveToken } from "../../fingerprint.js";
21
+ import * as api from "../../api.js";
22
+ import { operation } from "../../supervisor-registry.js";
23
+ import { readBody } from "../request-utils.js";
24
+ import { invalidateServiceStatusCache, getCachedServiceStatus } from "../../services/service-status.js";
25
+ import { writeCredentialWithRecovery } from "../../recovery.js";
26
+ import * as webdavService from "../../webdav-service.js";
27
+ import { removeMount as webdavRemoveMount } from "../../webdav-config.js";
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import os from "node:os";
31
+
32
+ const UNKNOWN_SERVICE_THRESHOLD = 2;
33
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
34
+
35
+ export function registerServiceCrudRoutes(ctx) {
36
+ ctx.registerPatternRoute("DELETE", /^\/webdav\/mounts\/(.+)$/, async (req, res, match, url) => {
37
+ if (ctx.writeGuard(req, res)) return;
38
+ const mountName = decodeURIComponent(match[1]);
39
+ const result = webdavRemoveMount(mountName);
40
+ if (result.error) {
41
+ operation("webdav.mount_remove", { name: mountName }, null, { ok: false, error: result.error });
42
+ res.writeHead(404, { "Content-Type": "application/json", ...ctx.CORS }); return res.end(JSON.stringify(result));
43
+ }
44
+ webdavService.shutdown();
45
+ operation("webdav.mount_remove", { name: mountName }, null, { ok: true });
46
+ return ctx.ok(res, result);
47
+ });
48
+ // GET /get/:service
49
+ ctx.registerPatternRoute("GET", /^\/get\/([a-zA-Z0-9_-]+)$/, async (req, res, match, url) => {
50
+ if (!ctx.password) return ctx.noBrowserJson(res, 401, { error: "Vault is locked", locked: true });
51
+ const service = match[1].toLowerCase();
52
+ const callerIp = req.socket.remoteAddress || "unknown";
53
+
54
+ if (ctx.whitelist && !ctx.whitelist.includes(service)) {
55
+ return ctx.strikeNoBrowser(res, 403, `Service '${service}' not in whitelist`);
56
+ }
57
+
58
+ try {
59
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
60
+ const result = await api.retrieve(ctx.password, ctx.machineHash, token, timestamp, service);
61
+
62
+ if (result.error) {
63
+ // Caller-side retrieval misses are not auth attacks. Do not burn the
64
+ // failure budget or make /ping look locked just because a script used
65
+ // stale metadata or walked disabled services.
66
+ const clientError = ctx.retrievalClientError(result.error);
67
+ if (clientError) {
68
+ const misses = ctx.logRetrievalClientError(clientError.logType, service, callerIp);
69
+
70
+ if (misses >= UNKNOWN_SERVICE_THRESHOLD) {
71
+ // Return the list of enabled service names so the caller can self-correct.
72
+ try {
73
+ const { token: lst, timestamp: lsts } = deriveToken(ctx.password, ctx.machineHash);
74
+ const statusResult = await getCachedServiceStatus({ password: ctx.password, machineHash: ctx.machineHash, token: lst, timestamp: lsts, whitelist: ctx.whitelist });
75
+ const available = (statusResult.services || [])
76
+ .filter(s => s.enabled !== false)
77
+ .filter(s => !ctx.whitelist || ctx.whitelist.includes(s.name.toLowerCase()))
78
+ .map(s => s.name.toLowerCase());
79
+ return ctx.noBrowserJson(res, clientError.code, { error: clientError.jsonError, service, available });
80
+ } catch {
81
+ // If status call fails, still return 404 without the list
82
+ }
83
+ }
84
+
85
+ return ctx.noBrowserJson(res, clientError.code, {
86
+ error: clientError.jsonError,
87
+ service,
88
+ hint: "Use GET /knowledge or /list-services before fetching a raw service",
89
+ });
90
+ }
91
+
92
+ // Real errors (wrong token, vault locked, etc.) still burn the failure budget.
93
+ return ctx.strikeNoBrowser(res, 502, result.error);
94
+ }
95
+
96
+ // Success — clear the unknown-service miss counter for this IP.
97
+ ctx.unknownServiceStrikes.delete(callerIp);
98
+ return ctx.noBrowserJson(res, 200, { service, value: result.value, key_type: result.key_type });
99
+ } catch (err) {
100
+ return ctx.strikeNoBrowser(res, 502, err.message);
101
+ }
102
+ });
103
+ // GET /v/:service — plain-text credential value (shorter than /get/:service + python3 pipe)
104
+ ctx.registerPatternRoute("GET", /^\/v\/([a-zA-Z0-9_-]+)$/, async (req, res, match, url) => {
105
+ if (!ctx.password) {
106
+ return ctx.noBrowserText(res, 403, "locked");
107
+ }
108
+ const service = match[1].toLowerCase();
109
+ const callerIp = req.socket.remoteAddress || "unknown";
110
+
111
+ if (ctx.whitelist && !ctx.whitelist.includes(service)) {
112
+ return ctx.noBrowserText(res, 403, "not allowed");
113
+ }
114
+
115
+ try {
116
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
117
+ const result = await api.retrieve(ctx.password, ctx.machineHash, token, timestamp, service);
118
+
119
+ if (result.error) {
120
+ const clientError = ctx.retrievalClientError(result.error);
121
+ if (clientError) {
122
+ ctx.logRetrievalClientError(clientError.logType, service, callerIp);
123
+ return ctx.noBrowserText(res, clientError.code, clientError.text);
124
+ }
125
+ // A backend/DB error surfaced in the body is NOT a client auth failure — no strike.
126
+ const backendKind = api.classifyServerReason(result.error);
127
+ if (backendKind) {
128
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [BACKEND ${backendKind}] /v/${service} retrieve — backend unreachable, no strike: ${result.error}\n`); } catch {}
129
+ return ctx.noBrowserText(res, 503, `vault backend unavailable (${backendKind})`);
130
+ }
131
+ return ctx.strikeNoBrowser(res, 502, result.error);
132
+ }
133
+
134
+ ctx.unknownServiceStrikes.delete(callerIp);
135
+ return ctx.noBrowserText(res, 200, result.value);
136
+ } catch (err) {
137
+ // Timeout / network / 5xx / rate-limit: the vault never answered. Report
138
+ // it as a backend outage, not a credential failure — never strike.
139
+ const backendKind = api.classifyBackendError(err);
140
+ if (backendKind) {
141
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [BACKEND ${backendKind}] /v/${service} retrieve — ${err.detail || err.message}\n`); } catch {}
142
+ return ctx.noBrowserText(res, 503, `vault backend unavailable (${backendKind})`);
143
+ }
144
+ return ctx.strikeNoBrowser(res, 502, err.message);
145
+ }
146
+ });
147
+ // POST /rename/:service — rename a service
148
+ ctx.registerPatternRoute("POST", /^\/rename\/([a-zA-Z0-9_-]+)$/, async (req, res, match, url) => {
149
+ if (ctx.writeGuard(req, res)) return;
150
+ const service = match[1].toLowerCase();
151
+ let body;
152
+ try { body = await readBody(req); } catch {
153
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
154
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
155
+ }
156
+ const newName = (body.new_name || "").trim().toLowerCase();
157
+ if (!newName || !/^[a-z0-9_-]+$/.test(newName)) {
158
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
159
+ return res.end(JSON.stringify({ error: "Invalid name — use lowercase letters, numbers, hyphens, underscores" }));
160
+ }
161
+ try {
162
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
163
+ const result = await api.updateService(ctx.password, ctx.machineHash, token, timestamp, service, { name: newName, label: newName });
164
+ if (result.error) {
165
+ operation("service.rename", { service, new_name: newName }, null, { ok: false, error: result.error });
166
+ return ctx.strike(res, 502, result.error);
167
+ }
168
+ invalidateServiceStatusCache(ctx.machineHash);
169
+ operation("service.rename", { service, new_name: newName }, null, { ok: true });
170
+ return ctx.ok(res, { ok: true, old_name: service, new_name: newName });
171
+ } catch (err) {
172
+ operation("service.rename", { service, new_name: newName }, null, { ok: false, error: err.message });
173
+ return ctx.strike(res, 502, err.message);
174
+ }
175
+ });
176
+ // POST /delete/:service — remove a service entirely
177
+ ctx.registerPatternRoute("POST", /^\/delete\/([a-zA-Z0-9_-]+)$/, async (req, res, match, url) => {
178
+ if (ctx.writeGuard(req, res)) return;
179
+ const service = match[1].toLowerCase();
180
+ try {
181
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
182
+ const result = await api.removeService(ctx.password, ctx.machineHash, token, timestamp, service, `CONFIRM REMOVE ${service.toUpperCase()}`);
183
+ if (result.error) {
184
+ operation("service.delete", { service }, null, { ok: false, error: result.error });
185
+ return ctx.strike(res, 502, result.error);
186
+ }
187
+ invalidateServiceStatusCache(ctx.machineHash);
188
+ operation("service.delete", { service }, null, { ok: true });
189
+ return ctx.ok(res, { ok: true, deleted: service });
190
+ } catch (err) {
191
+ operation("service.delete", { service }, null, { ok: false, error: err.message });
192
+ return ctx.strike(res, 502, err.message);
193
+ }
194
+ });
195
+ // POST /toggle/:service — enable or disable a service
196
+ ctx.registerPatternRoute("POST", /^\/toggle\/([a-zA-Z0-9_-]+)$/, async (req, res, match, url) => {
197
+ if (ctx.writeGuard(req, res)) return;
198
+ const service = match[1].toLowerCase();
199
+
200
+ let body;
201
+ try { body = await readBody(req); } catch {
202
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
203
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
204
+ }
205
+
206
+ const { enabled } = body;
207
+ if (typeof enabled !== "boolean") {
208
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
209
+ return res.end(JSON.stringify({ error: "enabled must be boolean" }));
210
+ }
211
+
212
+ try {
213
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
214
+ const result = await api.enable(ctx.password, ctx.machineHash, token, timestamp, service, enabled);
215
+ if (result.error) {
216
+ operation("service.toggle", { service, enabled }, null, { ok: false, error: result.error });
217
+ return ctx.strike(res, 502, result.error);
218
+ }
219
+ invalidateServiceStatusCache(ctx.machineHash);
220
+ operation("service.toggle", { service, enabled }, null, { ok: true });
221
+ return ctx.ok(res, { ok: true, service, enabled });
222
+ } catch (err) {
223
+ operation("service.toggle", { service, enabled }, null, { ok: false, error: err.message });
224
+ return ctx.strike(res, 502, err.message);
225
+ }
226
+ });
227
+ // ── Key Rotation Endpoints ──────────────────────────────────
228
+ // POST /rotate/:service — manually trigger rotation for a service
229
+ ctx.registerPatternRoute("POST", /^\/rotate\/(.+)$/, async (req, res, match, url) => {
230
+ if (ctx.writeGuard(req, res)) return;
231
+ const service = decodeURIComponent(match[1]);
232
+ if (!service) {
233
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
234
+ return res.end(JSON.stringify({ error: "Service name required" }));
235
+ }
236
+ const result = await ctx.rotationEngine.rotateService(service);
237
+ operation("service.rotate", { service }, null, { ok: !result?.error, error: result?.error });
238
+ return ctx.ok(res, result);
239
+ });
240
+ // POST /set-expiry/:service — set expiry date for a service
241
+ ctx.registerPatternRoute("POST", /^\/set-expiry\/(.+)$/, async (req, res, match, url) => {
242
+ if (ctx.writeGuard(req, res)) return;
243
+ const service = decodeURIComponent(match[1]);
244
+ let body;
245
+ try { body = await readBody(req); } catch {
246
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
247
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
248
+ }
249
+ ctx.rotationEngine.setExpiry(service, body.expires_at, body.rotation_days);
250
+ operation("service.set_expiry", { service }, null, { ok: true, expires_at: body.expires_at, rotation_days: body.rotation_days });
251
+ return ctx.ok(res, { ok: true, service, expires_at: body.expires_at });
252
+ });
253
+ // POST /set/:service — write a new key value into vault
254
+ ctx.registerPatternRoute("POST", /^\/set\/([a-zA-Z0-9_-]+)$/, async (req, res, match, url) => {
255
+ if (ctx.writeGuard(req, res)) return;
256
+ const service = match[1].toLowerCase();
257
+
258
+ if (ctx.whitelist && !ctx.whitelist.includes(service)) {
259
+ return ctx.strike(res, 403, `Service '${service}' not in whitelist`);
260
+ }
261
+
262
+ let body;
263
+ try { body = await readBody(req); } catch {
264
+ return ctx.strike(res, 400, "Invalid JSON body");
265
+ }
266
+
267
+ const value = body.value;
268
+ if (!value || typeof value !== "string" || !value.trim()) {
269
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
270
+ return res.end(JSON.stringify({ error: "value is required" }));
271
+ }
272
+
273
+ try {
274
+ // NEVER log `value` — it is the secret being written.
275
+ const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password: ctx.password, machineHash: ctx.machineHash, service, value, logFile: LOG_FILE });
276
+ if (result.error) {
277
+ operation("service.set", { service }, null, { ok: false, error: result.error });
278
+ return ctx.strike(res, 502, result.error);
279
+ }
280
+ invalidateServiceStatusCache(ctx.machineHash);
281
+ operation("service.set", { service }, null, { ok: true, normalized });
282
+ return ctx.ok(res, { ok: true, service, recovery_snapshot: !!snapshot?.ok, normalized });
283
+ } catch (err) {
284
+ operation("service.set", { service }, null, { ok: false, error: err.message });
285
+ return ctx.strike(res, 502, err.message);
286
+ }
287
+ });
288
+ }
@@ -0,0 +1,104 @@
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
5
+ // write-token, a THIRD distinct guard from lockedGuard/writeGuard and
6
+ // from the ops plane's bearer token), except /v1/surfaces/health which
7
+ // is deliberately guard-free (read-only health sweep, run whenever the
8
+ // dashboard tab regains focus -- gating it would prompt for a vault
9
+ // unlock just to look at the tab). GET /v1/operations/:id is also
10
+ // guard-free (a read of the supervisor's own event log).
11
+ //
12
+ // hasSupervisorWrite/rejectSupervisorWrite/supervisorLogDto stay defined
13
+ // in serve.js (closure state: writeSession/isSupervisorPort/port, and
14
+ // supervisorLogDto's own helper functions) and are passed via ctx as
15
+ // plain function references -- same avoid-circular-import reasoning as
16
+ // every other component this session.
17
+ import {
18
+ discoverPlugins,
19
+ setPluginEnabled,
20
+ runPluginAction,
21
+ probeAllSurfaceHealth,
22
+ runSurfaceAction,
23
+ addTunnelRoute,
24
+ removeTunnelRoute,
25
+ supervisorHealth,
26
+ readSupervisorEvents,
27
+ } from "../../supervisor-registry.js";
28
+ import { readBody } from "../request-utils.js";
29
+
30
+ // Returns an async (req, res, method, reqPath, url) => void handler. The
31
+ // caller's contract: after awaiting it, check res.writableEnded -- every
32
+ // branch below that matches a route always ends the response before
33
+ // returning (ok()/res.end()/noBrowserJson-shaped calls), exactly as the
34
+ // original if-chain did, so that flag is a reliable "did this handle the
35
+ // request" signal with zero change to the extracted control flow.
36
+ export function registerSupervisorRoutes(ctx) {
37
+ return async function handleSupervisorRoute(req, res, method, reqPath, url) {
38
+ if (method === "POST" && reqPath === "/v1/plugins/rescan") {
39
+ if (!ctx.hasSupervisorWrite(req)) return ctx.rejectSupervisorWrite(res);
40
+ return ctx.ok(res, discoverPlugins());
41
+ }
42
+
43
+ const pluginEnableMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable|test|promote)$/);
44
+ if (method === "POST" && pluginEnableMatch) {
45
+ 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);
53
+ res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...ctx.CORS });
54
+ return res.end(JSON.stringify(result));
55
+ }
56
+
57
+ // POST /v1/surfaces/health — read-only health sweep over every surface.
58
+ // Deliberately NOT behind ctx.hasSupervisorWrite(): it cannot start, stop or
59
+ // restart anything, and it is what the dashboard runs when the window
60
+ // regains focus. Gating it would make simply looking at the tab prompt for
61
+ // a vault unlock.
62
+ if (method === "POST" && reqPath === "/v1/surfaces/health") {
63
+ const result = await probeAllSurfaceHealth();
64
+ return ctx.ok(res, result);
65
+ }
66
+
67
+ const surfaceActionMatch = reqPath.match(/^\/v1\/surfaces\/([^/]+)\/actions$/);
68
+ if (method === "POST" && surfaceActionMatch) {
69
+ if (!ctx.hasSupervisorWrite(req)) return ctx.rejectSupervisorWrite(res);
70
+ let body;
71
+ try { body = await readBody(req); } catch {
72
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
73
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
74
+ }
75
+ const result = runSurfaceAction(decodeURIComponent(surfaceActionMatch[1]), body?.action || "reconcile");
76
+ res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...ctx.CORS });
77
+ return res.end(JSON.stringify(result));
78
+ }
79
+
80
+ const tunnelRoutesMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes$/);
81
+ if (method === "POST" && tunnelRoutesMatch) {
82
+ if (!ctx.hasSupervisorWrite(req)) return ctx.rejectSupervisorWrite(res);
83
+ let body;
84
+ try { body = await readBody(req); } catch {
85
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
86
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
87
+ }
88
+ return ctx.ok(res, addTunnelRoute(decodeURIComponent(tunnelRoutesMatch[1]), body, "localhost"));
89
+ }
90
+
91
+ const tunnelRouteDeleteMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes\/([^/]+)$/);
92
+ if (method === "DELETE" && tunnelRouteDeleteMatch) {
93
+ if (!ctx.hasSupervisorWrite(req)) return ctx.rejectSupervisorWrite(res);
94
+ return ctx.ok(res, removeTunnelRoute(decodeURIComponent(tunnelRouteDeleteMatch[1]), decodeURIComponent(tunnelRouteDeleteMatch[2]), "localhost"));
95
+ }
96
+
97
+ if (method === "GET" && reqPath.startsWith("/v1/operations/")) {
98
+ const id = decodeURIComponent(reqPath.split("/").pop());
99
+ const operation = (supervisorHealth(), readSupervisorEvents(500)).find((event) => event.operationId === id);
100
+ res.writeHead(operation ? 200 : 404, { "Content-Type": "application/json", ...ctx.CORS });
101
+ return res.end(JSON.stringify(operation ? ctx.supervisorLogDto(operation) : { error: "operation_not_found" }));
102
+ }
103
+ };
104
+ }
@@ -0,0 +1,184 @@
1
+ // cli/http/components/system-component.js
2
+ // The System component: daemon self-lifecycle -- restart (spawn a fresh
3
+ // process, exit self), shutdown-ui (user-initiated shutdown, clears
4
+ // boot.key so the watchdog won't auto-unlock on the next start), and
5
+ // make-live (blue-green promotion: a staged instance stops the current
6
+ // live daemon, spawns itself onto the live port, waits for it to answer,
7
+ // then exits). All three ultimately call process.exit() -- this is the
8
+ // daemon managing its own process lifecycle, registered via dependency
9
+ // injection like every other component (ctx exposes the createServer
10
+ // closure state these handlers read: port/password/whitelist/isStaged/
11
+ // tunnelHostname, plus removePid/removeStagedPid/getBootKeyPath, which
12
+ // stay in serve.js to avoid a circular import back into this file).
13
+ import { operation } from "../../supervisor-registry.js";
14
+ import { readBody } from "../request-utils.js";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import os from "node:os";
19
+
20
+ const LIVE_PORT = 52437;
21
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
22
+ const CLI_ENTRY_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../index.js");
23
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
24
+
25
+ export function registerSystemRoutes(ctx) {
26
+ // POST /restart — spawn fresh process then exit (keeps boot.key, vault stays unlocked).
27
+ // Optional body { pull: true } runs `git pull` + `npm install` in the repo
28
+ // root FIRST, synchronously, before respawning — so the dashboard's
29
+ // Restart button can offer "just restart" vs "pull latest + reinstall,
30
+ // then restart" without a second route. Synchronous is deliberate: the
31
+ // daemon is about to restart anyway (a real outage either way), and a
32
+ // dashboard-triggered git pull is a rare, explicit, single-operator action
33
+ // -- not a case that needs to stay responsive to other traffic mid-pull.
34
+ ctx.registerWriteRoute("POST", "/restart", async (req, res) => {
35
+ let body = {};
36
+ try { body = await readBody(req); } catch {}
37
+ const pullLatest = !!body.pull;
38
+
39
+ if (pullLatest) {
40
+ const { execSync } = await import("node:child_process");
41
+ try {
42
+ const gitOut = execSync("git pull", { cwd: REPO_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
43
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] Restart: git pull —\n${gitOut}\n`); } catch {}
44
+ const npmOut = execSync("npm install", { cwd: REPO_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
45
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] Restart: npm install —\n${npmOut}\n`); } catch {}
46
+ } catch (err) {
47
+ const detail = (err.stdout || "") + (err.stderr || err.message || "");
48
+ operation("daemon.restart_pull_failed", { port: ctx.port }, null, { ok: false, error: detail });
49
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] Restart: pull/install FAILED —\n${detail}\n`); } catch {}
50
+ res.writeHead(500, { "Content-Type": "application/json", ...ctx.CORS });
51
+ return res.end(JSON.stringify({ error: "git pull / npm install failed — daemon NOT restarted", detail }));
52
+ }
53
+ }
54
+
55
+ operation("daemon.restart_requested", { port: ctx.port, pulled: pullLatest }, null, { ok: true });
56
+ ctx.ok(res, { ok: true, message: pullLatest ? "pulled latest, restarting" : "restarting" });
57
+ const { spawn } = await import("child_process");
58
+ const cliEntry = CLI_ENTRY_PATH;
59
+ const childArgs = [cliEntry, "serve", "start", "--port", String(ctx.port)];
60
+ if (ctx.password) childArgs.push("--pw", ctx.password);
61
+ if (ctx.whitelist) childArgs.push("--services", ctx.whitelist.join(","));
62
+ if (ctx.tunnelHostname) childArgs.push("--tunnel", ctx.tunnelHostname);
63
+ const out = fs.openSync(LOG_FILE, "a");
64
+ const child = spawn(process.execPath, childArgs, {
65
+ detached: true,
66
+ stdio: ["ignore", out, out],
67
+ env: { ...process.env, __CLAUTH_DAEMON: "1" },
68
+ windowsHide: true,
69
+ });
70
+ child.unref();
71
+ ctx.stopTunnel();
72
+ ctx.removePid();
73
+ setTimeout(() => process.exit(0), 300);
74
+ return;
75
+ });
76
+ ctx.registerWriteRoute("POST", "/shutdown-ui", async (req, res) => {
77
+ operation("daemon.shutdown_ui", { port: ctx.port }, null, { ok: true });
78
+ ctx.stopTunnel();
79
+ // Clear boot.key so watchdog can't auto-unlock on restart
80
+ const bootKeyPath = ctx.getBootKeyPath();
81
+ if (bootKeyPath) {
82
+ try { fs.unlinkSync(bootKeyPath); } catch {}
83
+ }
84
+ ctx.ok(res, { ok: true, message: "shutting down (user-initiated, boot.key cleared)" });
85
+ const logLine = `[${new Date().toISOString()}] User-initiated shutdown via UI — boot.key cleared\n`;
86
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
87
+ ctx.removePid();
88
+ setTimeout(() => process.exit(0), 100);
89
+ return;
90
+ });
91
+ // GET|POST /shutdown — programmatic daemon stop, keeps boot.key (accepts GET
92
+ // too: older scripts and curl default to GET). Also the cross-process call
93
+ // /make-live's own promotion flow makes against the CURRENT live daemon.
94
+ const shutdownHandler = async (req, res) => {
95
+ operation("daemon.shutdown", { port: ctx.port }, null, { ok: true });
96
+ ctx.stopTunnel();
97
+ ctx.ok(res, { ok: true, message: "shutting down" });
98
+ ctx.removePid();
99
+ setTimeout(() => process.exit(0), 100);
100
+ return;
101
+ };
102
+ ctx.registerWriteRoute("GET", "/shutdown", shutdownHandler);
103
+ ctx.registerWriteRoute("POST", "/shutdown", shutdownHandler);
104
+
105
+ ctx.registerWriteRoute("POST", "/make-live", async (req, res) => {
106
+ if (!ctx.isStaged) {
107
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
108
+ return res.end(JSON.stringify({ error: "Not a staged instance" }));
109
+ }
110
+ const logLine = `[${new Date().toISOString()}] Make-live: promoting staged (port ${ctx.port}) to live (port ${LIVE_PORT})\n`;
111
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
112
+
113
+ // Stop the current live daemon if running
114
+ try { await fetch(`http://127.0.0.1:${LIVE_PORT}/shutdown`); } catch {}
115
+
116
+ // Wait for port to be released (Windows is slow to release sockets)
117
+ let portFree = false;
118
+ for (let i = 0; i < 10; i++) {
119
+ await new Promise(r => setTimeout(r, 1000));
120
+ try {
121
+ await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
122
+ // Still responding — not dead yet
123
+ } catch {
124
+ portFree = true;
125
+ break;
126
+ }
127
+ }
128
+ if (!portFree) {
129
+ const errLog = `[${new Date().toISOString()}] Make-live: live daemon on port ${LIVE_PORT} failed to stop within 10s\n`;
130
+ try { fs.appendFileSync(LOG_FILE, errLog); } catch {}
131
+ }
132
+
133
+ // Stop tunnel on this instance (will restart on new port)
134
+ ctx.stopTunnel();
135
+
136
+ // Clean up staged PID file
137
+ ctx.removeStagedPid();
138
+
139
+ // Spawn a new daemon on the live port with same credentials
140
+ const { spawn } = await import("child_process");
141
+ const cliEntry = CLI_ENTRY_PATH;
142
+ const childArgs = [cliEntry, "serve", "start", "--port", String(LIVE_PORT)];
143
+ if (ctx.password) childArgs.push("--pw", ctx.password);
144
+ if (ctx.whitelist) childArgs.push("--services", ctx.whitelist.join(","));
145
+ if (ctx.tunnelHostname) childArgs.push("--tunnel", ctx.tunnelHostname);
146
+
147
+ const out = fs.openSync(LOG_FILE, "a");
148
+ const childEnv = { ...process.env, __CLAUTH_DAEMON: "1" };
149
+ delete childEnv.__CLAUTH_STAGED; // Clear staged flag — child is live
150
+ const child = spawn(process.execPath, childArgs, {
151
+ detached: true,
152
+ stdio: ["ignore", out, out],
153
+ env: childEnv,
154
+ windowsHide: true,
155
+ });
156
+ child.unref();
157
+
158
+ // Wait for new live daemon to be reachable
159
+ let promoted = false;
160
+ for (let i = 0; i < 8; i++) {
161
+ await new Promise(r => setTimeout(r, 1000));
162
+ try {
163
+ const resp = await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
164
+ if (resp.ok) { promoted = true; break; }
165
+ } catch {}
166
+ }
167
+
168
+ if (promoted) {
169
+ const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
170
+ try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
171
+ operation("daemon.make_live", { from_port: ctx.port, live_port: LIVE_PORT }, null, { ok: true });
172
+ ctx.ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
173
+ } else {
174
+ const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
175
+ try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
176
+ operation("daemon.make_live", { from_port: ctx.port, live_port: LIVE_PORT }, null, { ok: false, error: "new daemon failed to start" });
177
+ ctx.ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
178
+ }
179
+
180
+ // Self-terminate after response is sent
181
+ setTimeout(() => process.exit(0), 500);
182
+ return;
183
+ });
184
+ }