@lifeaitools/clauth 2.1.1 → 2.10.0

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 +2613 -11219
  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 +98 -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,264 @@
1
+ // cli/http/components/vault-auth-component.js
2
+ // The Vault/Auth component: the actual security core -- vault unlock
3
+ // (/auth), lock (/lock), write-token issuance (/write-token), and master
4
+ // password change (/change-pw). Every closure variable these routes
5
+ // read or mutate (password, writeSession, authFailCount, authHardLocked,
6
+ // tunnelHostname, tunnelStatus) is exposed via ctx as get/set accessor
7
+ // properties -- same pattern as the Tunnel component's ctx.tunnel, just
8
+ // flattened here since these 6 span multiple concerns (auth state,
9
+ // tunnel state) that don't share one natural sub-object.
10
+ //
11
+ // rotationEngine/fetchTunnelConfig/startTunnel/stopTunnel/
12
+ // invalidateCallAgentCtxCache/isTerminalAuthVerdict all stay defined in
13
+ // serve.js and are passed via ctx as plain references -- avoid-circular-
14
+ // import reasoning, same as every other component this session.
15
+ import { deriveToken, deriveSeedHash } from "../../fingerprint.js";
16
+ import * as api from "../../api.js";
17
+ import { operation } from "../../supervisor-registry.js";
18
+ import { readBody } from "../request-utils.js";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import crypto from "node:crypto";
23
+ import { execSync } from "node:child_process";
24
+
25
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
26
+ const MAX_AUTH_FAILS = 10;
27
+ const WRITE_TOKEN_BYTES = 32;
28
+ const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
29
+
30
+ function makeWriteToken() {
31
+ return {
32
+ token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
33
+ expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
34
+ };
35
+ }
36
+
37
+ export function registerVaultAuthRoutes(ctx) {
38
+ // POST /write-token — hand the dashboard a current write token for an
39
+ // ALREADY-UNLOCKED vault, with no second password prompt.
40
+ //
41
+ // A human looking at the dashboard has already passed the lock screen (or
42
+ // the daemon was auto-unlocked via --pw/boot.key). Making them re-enter the
43
+ // password to press a button is ceremony, not security: GET / at the route
44
+ // above already embeds a write token on exactly this condition. This route
45
+ // just lets the page RENEW it, which GET / could only do on a full reload.
46
+ //
47
+ // Why the write token still exists at all: every request reaching this
48
+ // server is already loopback-only (hard 403 above), but cloudflared proxies
49
+ // the public tunnel FROM localhost, so a remote request and a local one are
50
+ // indistinguishable by address. The token is what a page-driven write has
51
+ // and a blind remote POST does not, so the gate stays; only the prompt goes.
52
+ //
53
+ // Mint-or-reuse, mirroring GET /: reusing a still-valid session keeps
54
+ // multiple open tabs working instead of each one invalidating the last.
55
+ ctx.registerWriteRoute("POST", "/write-token", async (req, res) => {
56
+ if (!ctx.password) {
57
+ res.writeHead(403, { "Content-Type": "application/json", ...ctx.CORS });
58
+ return res.end(JSON.stringify({ error: "vault_locked", locked: true }));
59
+ }
60
+ if (!ctx.writeSession || Date.now() > ctx.writeSession.expiresAt) ctx.writeSession = makeWriteToken();
61
+ return ctx.ok(res, {
62
+ ok: true,
63
+ write_token: ctx.writeSession.token,
64
+ write_expires_at: new Date(ctx.writeSession.expiresAt).toISOString(),
65
+ });
66
+ });
67
+ // POST /auth — unlock the vault with a password (verifies against Edge Function)
68
+ ctx.registerWriteRoute("POST", "/auth", async (req, res) => {
69
+ let body;
70
+ try { body = await readBody(req); } catch {
71
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
72
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
73
+ }
74
+
75
+ const pw = body.password;
76
+ if (!pw || typeof pw !== "string") {
77
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
78
+ return res.end(JSON.stringify({ error: "password is required" }));
79
+ }
80
+
81
+ if (ctx.authHardLocked) {
82
+ res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
83
+ return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", hard_locked: true }));
84
+ }
85
+
86
+ try {
87
+ const { token, timestamp } = deriveToken(pw, ctx.machineHash);
88
+ const result = await api.test(pw, ctx.machineHash, token, timestamp);
89
+ if (result.error) {
90
+ // Preserve the server's `reason` (invalid_token (N/5), machine_locked, …)
91
+ // so the catch block can surface it AND decide whether to keep retrying.
92
+ const authError = new Error(result.error);
93
+ authError.serverReason = result.reason || null;
94
+ throw authError;
95
+ }
96
+ ctx.password = pw; // unlock — store in process memory only
97
+ ctx.writeSession = makeWriteToken();
98
+ ctx.authFailCount = 0;
99
+ ctx.authHardLocked = false;
100
+ const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
101
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
102
+ // Start rotation engine on unlock
103
+ ctx.rotationEngine.start();
104
+ // Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
105
+ // Only on Windows; only if autostart dir exists (i.e., install was run)
106
+ if (os.platform() === "win32") {
107
+ const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
108
+ const bootKeyPath = path.join(autostartDir, "boot.key");
109
+ if (fs.existsSync(autostartDir)) {
110
+ try {
111
+ const pwEscaped = pw.replace(/'/g, "''");
112
+ const psExpr = `Add-Type -AssemblyName System.Security; [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
113
+ const psExe = process.env.SystemRoot
114
+ ? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
115
+ : "powershell.exe";
116
+ const encrypted = execSync(`"${psExe}" -NoProfile -Command "${psExpr}"`, { encoding: "utf8", timeout: 5000 }).trim();
117
+ fs.writeFileSync(bootKeyPath, encrypted, "utf8");
118
+ const sealLog = `[${new Date().toISOString()}] Auto-sealed boot.key via DPAPI (crash recovery enabled)\n`;
119
+ try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
120
+ } catch (sealErr) {
121
+ const sealLog = `[${new Date().toISOString()}] Auto-seal failed (non-fatal): ${sealErr.message}\n`;
122
+ try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
123
+ }
124
+ }
125
+ }
126
+ // Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
127
+ if (!ctx.tunnelHostname) {
128
+ ctx.fetchTunnelConfig().then(configured => {
129
+ if (configured) {
130
+ ctx.tunnelHostname = configured;
131
+ ctx.tunnelStatus = "starting";
132
+ ctx.startTunnel().catch(() => {});
133
+ } else {
134
+ // No tunnel configured in DB and no --tunnel flag
135
+ ctx.tunnelStatus = "not_configured";
136
+ const msg = [
137
+ `[${new Date().toISOString()}] No tunnel configured.`,
138
+ " claude.ai web integration is inactive.",
139
+ " To enable: run 'clauth tunnel setup'",
140
+ ].join("\n");
141
+ try { fs.appendFileSync(LOG_FILE, msg + "\n"); } catch {}
142
+ console.log("\n ⚠ No tunnel configured — claude.ai web integration inactive.");
143
+ console.log(" Run: clauth tunnel setup\n");
144
+ }
145
+ }).catch(() => {
146
+ ctx.tunnelStatus = "error";
147
+ });
148
+ } else {
149
+ ctx.tunnelStatus = "starting";
150
+ ctx.startTunnel().catch(() => {});
151
+ }
152
+ operation("vault.unlock", {}, null, { ok: true });
153
+ return ctx.ok(res, { ok: true, locked: false, write_token: ctx.writeSession.token, write_expires_at: new Date(ctx.writeSession.expiresAt).toISOString() });
154
+ } catch (authErr) {
155
+ const msg = authErr.message || "";
156
+ const serverReason = authErr.serverReason || null;
157
+ // Classify FIRST: is this a backend/external-resource failure (the vault
158
+ // backend or its database never rendered an auth verdict), or a genuine
159
+ // credential verdict? A database timeout, a 5xx, a network drop, or a
160
+ // rate-limit is NOT a wrong password and MUST NOT take a strike — that is
161
+ // exactly what turned a DB blip into a permanent lockout. Both the thrown
162
+ // error (timeout/network/typed VaultBackendError) and any server `reason`
163
+ // (rate_limited/db_error) are checked.
164
+ const backendKind = api.classifyBackendError(authErr) || api.classifyServerReason(serverReason);
165
+ if (backendKind) {
166
+ const BACKEND_MSG = {
167
+ timeout: "Vault backend (database) is not responding — try again in a moment",
168
+ db_error: "Vault backend database error — try again in a moment",
169
+ network: "Vault backend is unreachable — check your network and try again",
170
+ rate_limited: "Vault backend is rate-limiting — wait a few seconds and retry",
171
+ server_error: "Vault backend error — try again in a moment",
172
+ };
173
+ const friendly = BACKEND_MSG[backendKind] || "Vault backend is unreachable — try again in a moment";
174
+ const detail = authErr.detail || msg;
175
+ // No strike, no hard-lock — the verdict was never rendered.
176
+ const failLog = `[${new Date().toISOString()}] [BACKEND ${backendKind}] vault backend unreachable, no auth strike — ${detail}\n`;
177
+ try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
178
+ operation("vault.unlock", {}, null, { ok: false, backend_error: true, kind: backendKind });
179
+ res.writeHead(503, { "Content-Type": "application/json", ...ctx.CORS });
180
+ return res.end(JSON.stringify({
181
+ error: friendly,
182
+ transport_error: true, // back-compat: existing lock screen renders this
183
+ backend_error: true,
184
+ kind: backendKind,
185
+ detail,
186
+ }));
187
+ }
188
+ // Server rendered a verdict. Surface its `reason` so the CLI/log shows
189
+ // WHY (invalid_token (N/5) vs machine_locked) instead of a bare "auth_failed".
190
+ const reasonSuffix = serverReason ? ` (${serverReason})` : "";
191
+ // STOP-ON-REJECTION: a server-side machine lock or invalid_token is terminal —
192
+ // each retry only burns another of the 5 server-side strikes toward a DB lockout.
193
+ // Hard-lock locally NOW so the browser/CLI cannot keep hammering the vault.
194
+ const isTerminalVerdict = ctx.isTerminalAuthVerdict(serverReason);
195
+ ctx.authFailCount++;
196
+ const authRemaining = MAX_AUTH_FAILS - ctx.authFailCount;
197
+ const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${ctx.authFailCount}/${MAX_AUTH_FAILS}] ${msg || "Wrong password"}${reasonSuffix}\n`;
198
+ try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
199
+ if (isTerminalVerdict) {
200
+ ctx.authHardLocked = true;
201
+ const lockLog = `[${new Date().toISOString()}] Server rejected with terminal verdict${reasonSuffix} — hard-locking locally to stop strike accrual; recover via the runbook (unlock machine + re-seal boot.key)\n`;
202
+ try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
203
+ operation("vault.unlock", {}, null, { ok: false, terminal: true, reason: serverReason });
204
+ res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
205
+ return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
206
+ }
207
+ if (ctx.authFailCount >= MAX_AUTH_FAILS) {
208
+ ctx.authHardLocked = true;
209
+ const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
210
+ try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
211
+ operation("vault.unlock", {}, null, { ok: false, hard_locked: true, reason: serverReason });
212
+ res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
213
+ return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
214
+ }
215
+ operation("vault.unlock", {}, null, { ok: false, reason: serverReason, failures_remaining: authRemaining });
216
+ res.writeHead(401, { "Content-Type": "application/json", ...ctx.CORS });
217
+ return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
218
+ }
219
+ });
220
+ // POST /lock — clear password from memory
221
+ ctx.registerWriteRoute("POST", "/lock", async (req, res) => {
222
+ ctx.password = null;
223
+ ctx.writeSession = null;
224
+ ctx.invalidateCallAgentCtxCache();
225
+ ctx.stopTunnel();
226
+ const logLine = `[${new Date().toISOString()}] Vault locked\n`;
227
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
228
+ operation("vault.lock", {}, null, { ok: true });
229
+ return ctx.ok(res, { ok: true, locked: true, hard_locked: ctx.authHardLocked });
230
+ });
231
+ // POST /change-pw — change master password (must be unlocked)
232
+ ctx.registerWriteRoute("POST", "/change-pw", async (req, res) => {
233
+ if (ctx.writeGuard(req, res)) return;
234
+
235
+ let body;
236
+ try { body = await readBody(req); } catch {
237
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
238
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
239
+ }
240
+
241
+ const { newPassword } = body;
242
+ if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
243
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
244
+ return res.end(JSON.stringify({ error: "newPassword must be at least 8 characters" }));
245
+ }
246
+
247
+ try {
248
+ const { token, timestamp } = deriveToken(ctx.password, ctx.machineHash);
249
+ const newSeedHash = deriveSeedHash(ctx.machineHash, newPassword);
250
+ const result = await api.changePassword(ctx.password, ctx.machineHash, token, timestamp, newSeedHash);
251
+ if (result.error) throw new Error(result.error);
252
+ ctx.password = newPassword; // update in-memory password to new one
253
+ ctx.writeSession = makeWriteToken();
254
+ const logLine = `[${new Date().toISOString()}] Password changed\n`;
255
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
256
+ operation("vault.change_password", {}, null, { ok: true });
257
+ return ctx.ok(res, { ok: true, write_token: ctx.writeSession.token, write_expires_at: new Date(ctx.writeSession.expiresAt).toISOString() });
258
+ } catch (err) {
259
+ operation("vault.change_password", {}, null, { ok: false, error: err.message });
260
+ res.writeHead(502, { "Content-Type": "application/json", ...ctx.CORS });
261
+ return res.end(JSON.stringify({ error: err.message }));
262
+ }
263
+ });
264
+ }
@@ -0,0 +1,28 @@
1
+ // cli/http/components/watchdog-component.js
2
+ // The Watchdog component: read-only status/event views over
3
+ // watchdog-registry.js's child-service monitor, plus the one dynamic-segment
4
+ // mutation (restart a named watchdog-owned service). All three functions are
5
+ // real exports of watchdog-registry.js -- imported directly, no ctx needed
6
+ // for them since there's no circular-import risk (this component is never
7
+ // imported back into that module).
8
+ import {
9
+ getWatchdogStatuses,
10
+ readWatchdogEvents,
11
+ restartWatchdogService,
12
+ } from "../../watchdog-registry.js";
13
+
14
+ export function registerWatchdogRoutes(ctx) {
15
+ ctx.registerReadOnlyRoute("GET", "/watchdog/services", async (req, res) => ctx.ok(res, await getWatchdogStatuses()));
16
+
17
+ ctx.registerReadOnlyRoute("GET", "/watchdog/events", async (req, res, url) => {
18
+ const limit = Number(url.searchParams.get("limit") || 100);
19
+ return ctx.ok(res, { events: readWatchdogEvents(limit) });
20
+ });
21
+
22
+ // POST /watchdog/services/:name/restart
23
+ ctx.registerPatternRoute("POST", /^\/watchdog\/services\/([^/]+)\/restart$/, async (req, res, match) => {
24
+ const result = restartWatchdogService(decodeURIComponent(match[1]));
25
+ res.writeHead(result.ok ? 200 : 403, { "Content-Type": "application/json", ...ctx.CORS });
26
+ return res.end(JSON.stringify(result));
27
+ });
28
+ }
@@ -0,0 +1,21 @@
1
+ // cli/http/request-utils.js
2
+ // Small stateless HTTP request helpers shared across serve.js and the
3
+ // per-feature components under cli/http/components/. Pure functions only --
4
+ // nothing here closes over daemon instance state.
5
+ export function readBody(req) {
6
+ return new Promise((resolve, reject) => {
7
+ let data = "";
8
+ req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
9
+ req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } });
10
+ req.on("error", reject);
11
+ });
12
+ }
13
+
14
+ export function readRawBody(req) {
15
+ return new Promise((resolve, reject) => {
16
+ let data = "";
17
+ req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
18
+ req.on("end", () => resolve(data));
19
+ req.on("error", reject);
20
+ });
21
+ }
@@ -0,0 +1,335 @@
1
+ // cli/http/servers/ops-server.js
2
+ // The Ops server: a SEPARATE HTTP listener, its own port, for the
3
+ // PM2/Coolify/deployment operations plane. Bearer-token gated
4
+ // (requireOpsBearer), entirely distinct from the main daemon's vault-
5
+ // password gate -- this plane exists for remote/agent-driven
6
+ // infrastructure operations, not local vault access, so it gets its own
7
+ // port rather than sharing fate with the vault daemon's port/lifecycle.
8
+ //
9
+ // Fully self-contained: no reference to the main daemon's createServer
10
+ // closure state (password/whitelist/etc.) survives in this subsystem --
11
+ // confirmed by grep before extraction. createOpsServer() takes no
12
+ // dependency-injection context; it builds its own ops-job state.
13
+ import http from "node:http";
14
+ import path from "node:path";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import crypto from "node:crypto";
18
+ import { ensurePm2 } from "../../ops/pm2-preflight.js";
19
+ import { createPm2Adapter, PM2_OPERATION_CATALOG } from "../../ops/pm2-adapter.js";
20
+ import { createOperationPolicy } from "../../ops/operation-policy.js";
21
+ import { createJobStore } from "../../ops/job-store.js";
22
+ import { createSerializedExecutor } from "../../ops/serialized-executor.js";
23
+ import { createCoolifyAdapter, deploymentUuidFrom } from "../../ops/coolify-adapter.js";
24
+ import { createDeploymentAdapter, parseDeploymentRegistry } from "../../ops/deployment-adapter.js";
25
+ import { getSupervisorDir } from "../../supervisor-registry.js";
26
+ import { readBody } from "../request-utils.js";
27
+
28
+ const LIVE_PORT = 52437;
29
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
30
+
31
+ // Duplicated from serve.js's own isLoopbackAddress -- trivial, zero
32
+ // dependency, kept local to avoid a circular import back into serve.js
33
+ // (which imports createOpsServer from this file).
34
+ function isLoopbackAddress(remote) {
35
+ return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
36
+ }
37
+
38
+ const CORS = {
39
+ "Access-Control-Allow-Origin": "*",
40
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
41
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version, mcp-session-id",
42
+ };
43
+
44
+ function ok(res, data) {
45
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
46
+ res.end(JSON.stringify(data));
47
+ }
48
+
49
+ export async function createOpsServer() {
50
+ // pm2 is a real declared dependency, but was previously a static top-level
51
+ // import here -- a missing/corrupted node_modules/pm2 threw at MODULE-LOAD
52
+ // time, which (since serve.js imports this file unconditionally) crashed
53
+ // the entire clauth daemon, vault included, with no recovery attempt.
54
+ // ensurePm2() resolves it dynamically and repairs via `npm install` once
55
+ // before giving up, so a missing pm2 degrades only the ops plane's own
56
+ // startup, not clauth as a whole.
57
+ const pm2 = await ensurePm2();
58
+ const opsAdapter = createPm2Adapter(pm2);
59
+ const executePm2 = createSerializedExecutor();
60
+ const opsPolicy = createOperationPolicy({
61
+ enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
62
+ applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
63
+ adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
64
+ adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
65
+ allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
66
+ });
67
+ const opsJobs = createJobStore({
68
+ filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
69
+ });
70
+
71
+ async function getLoopbackSecret(service) {
72
+ const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
73
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
74
+ const value = (await response.text()).trim();
75
+ if (!value) throw new Error(`${service} is empty`);
76
+ return value;
77
+ }
78
+ const coolify = createCoolifyAdapter({
79
+ baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
80
+ getToken: () => getLoopbackSecret("coolify-api"),
81
+ });
82
+ const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
83
+ const deploymentAdapter = createDeploymentAdapter({
84
+ deployments,
85
+ reload: async (target) => {
86
+ await executePm2(async () => {
87
+ await opsAdapter.connect();
88
+ try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
89
+ });
90
+ },
91
+ });
92
+
93
+ /**
94
+ * Record an ops failure's upstream message to the LOCAL log only.
95
+ *
96
+ * job-store's sanitizer deliberately drops free-form `error` text so an
97
+ * upstream message cannot carry a credential into the persisted job file or
98
+ * the API response. That protection left every failure with an empty detail,
99
+ * so jobs reported `failed` with no reason at all. Jobs now carry an
100
+ * enumerated `code`; the underlying message goes here, to the same
101
+ * operator-only log as the rest of the daemon's diagnostics.
102
+ */
103
+ function logOpsFailure(kind, operation, error) {
104
+ const message = String(error?.message || error || "unknown");
105
+ try {
106
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
107
+ } catch {}
108
+ }
109
+
110
+ async function opsBearerRole(req) {
111
+ const header = req.headers.authorization;
112
+ const supplied = Array.isArray(header) ? header[0] : header;
113
+ if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
114
+ const actual = String(supplied).slice(7).trim();
115
+ let admin; let agent;
116
+ try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
117
+ try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
118
+ if (admin && agent && admin === agent) return null;
119
+ for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
120
+ if (!expected) continue;
121
+ const a = Buffer.from(actual); const b = Buffer.from(expected);
122
+ if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ async function requireOpsBearer(req, res) {
128
+ const role = await opsBearerRole(req);
129
+ if (role) { req._opsRole = role; return true; }
130
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
131
+ res.end(JSON.stringify({ error: "ops_bearer_required" }));
132
+ return false;
133
+ }
134
+
135
+ function submitOpsJob(operation, input, role = "agent") {
136
+ const authorization = opsPolicy.authorize(operation, input, role);
137
+ const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
138
+ if (!authorization.ok) {
139
+ return opsJobs.event(job.id, "rejected", { code: authorization.code });
140
+ }
141
+ void (async () => {
142
+ opsJobs.event(job.id, "running");
143
+ try {
144
+ const result = await executePm2(async () => {
145
+ await opsAdapter.connect();
146
+ try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
147
+ });
148
+ opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
149
+ } catch (error) {
150
+ // `error` alone is dropped by job-store's sanitizer (it refuses
151
+ // free-form upstream text so a credential cannot ride along), which
152
+ // left every failure with an empty detail. Emit an enumerated code so
153
+ // the failure has a reason; keep the message for the local log only.
154
+ logOpsFailure("pm2", operation, error);
155
+ opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
156
+ }
157
+ })();
158
+ return opsJobs.get(job.id);
159
+ }
160
+
161
+ function operationReceipt(operation, result, allowedTargets) {
162
+ if (["list", "describe", "logs"].includes(operation)) {
163
+ const processes = Array.isArray(result)
164
+ ? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
165
+ : [];
166
+ return { processes };
167
+ }
168
+ if (operation === "ping") return { status: "connected" };
169
+ return { status: "completed" };
170
+ }
171
+
172
+ function submitPromotionJob(applicationUuid) {
173
+ const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
174
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
175
+ const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
176
+ if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
177
+ return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
178
+ }
179
+ void (async () => {
180
+ opsJobs.event(job.id, "running");
181
+ try {
182
+ const deployment = await coolify.promote(applicationUuid);
183
+ // Coolify answers with a `deployments` ARRAY, not a flat object — see
184
+ // deploymentUuidFrom. Reading the flat field alone marked the job failed
185
+ // while the deployment was actually running.
186
+ const deploymentUuid = deploymentUuidFrom(deployment);
187
+ if (!deploymentUuid) {
188
+ // The deploy request itself SUCCEEDED (no throw); only the UUID was
189
+ // unreadable, so the deployment may well be RUNNING. The code says so
190
+ // explicitly rather than a bare "failed", because a plain failure
191
+ // invites a retry and a duplicate production deploy.
192
+ //
193
+ // An enumerated code, not a free-form error: job-store's sanitizer
194
+ // drops `error` on purpose to keep upstream text (and any credential
195
+ // inside it) out of the persisted job.
196
+ return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
197
+ }
198
+ opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
199
+ const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
200
+ opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
201
+ } catch (error) {
202
+ // The throw may have happened AFTER Coolify accepted the deploy (e.g.
203
+ // the poll lost the network), so this is not proof nothing shipped.
204
+ logOpsFailure("coolify", "promote", error);
205
+ opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
206
+ }
207
+ })();
208
+ return opsJobs.get(job.id);
209
+ }
210
+
211
+ function submitDeploymentJob(application, ref) {
212
+ const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
213
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
214
+ if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
215
+ void (async () => {
216
+ opsJobs.event(job.id, "running");
217
+ try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
218
+ catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
219
+ })();
220
+ return opsJobs.get(job.id);
221
+ }
222
+
223
+ return http.createServer(async (req, res) => {
224
+ const remote = req.socket.remoteAddress;
225
+ if (!isLoopbackAddress(remote)) {
226
+ res.writeHead(403, { "Content-Type": "application/json", ...CORS });
227
+ return res.end(JSON.stringify({ error: "loopback_only" }));
228
+ }
229
+ if (req.method === "OPTIONS") {
230
+ res.writeHead(204, CORS);
231
+ return res.end();
232
+ }
233
+ const url = new URL(req.url, "http://127.0.0.1");
234
+ const reqPath = url.pathname;
235
+ const method = req.method;
236
+
237
+ if (method === "GET" && reqPath === "/v1/ops/catalog") {
238
+ if (!await requireOpsBearer(req, res)) return;
239
+ return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
240
+ }
241
+
242
+ if (method === "GET" && reqPath === "/v1/ops/processes") {
243
+ if (!await requireOpsBearer(req, res)) return;
244
+ const job = submitOpsJob("list", {}, req._opsRole);
245
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
246
+ return res.end(JSON.stringify(job));
247
+ }
248
+
249
+ const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
250
+ if (method === "GET" && opsProcessMatch) {
251
+ if (!await requireOpsBearer(req, res)) return;
252
+ const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
253
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
254
+ return res.end(JSON.stringify(job));
255
+ }
256
+
257
+ if (method === "POST" && reqPath === "/v1/ops/operations") {
258
+ if (!await requireOpsBearer(req, res)) return;
259
+ let body;
260
+ try { body = await readBody(req); } catch {
261
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
262
+ return res.end(JSON.stringify({ error: "invalid_json" }));
263
+ }
264
+ const operation = String(body?.operation || "");
265
+ if (!PM2_OPERATION_CATALOG[operation]) {
266
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
267
+ return res.end(JSON.stringify({ error: "unknown_operation" }));
268
+ }
269
+ const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
270
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
271
+ return res.end(JSON.stringify(job));
272
+ }
273
+
274
+ if (method === "POST" && reqPath === "/v1/ops/promotions") {
275
+ if (!await requireOpsBearer(req, res)) return;
276
+ let body;
277
+ try { body = await readBody(req); } catch {
278
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
279
+ return res.end(JSON.stringify({ error: "invalid_json" }));
280
+ }
281
+ const applicationUuid = String(body?.application_uuid || "").trim();
282
+ if (!applicationUuid) {
283
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
284
+ return res.end(JSON.stringify({ error: "application_uuid_required" }));
285
+ }
286
+ const job = submitPromotionJob(applicationUuid);
287
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
288
+ return res.end(JSON.stringify(job));
289
+ }
290
+
291
+ if (method === "POST" && reqPath === "/v1/ops/deployments") {
292
+ if (!await requireOpsBearer(req, res)) return;
293
+ let body;
294
+ try { body = await readBody(req); } catch {
295
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
296
+ return res.end(JSON.stringify({ error: "invalid_json" }));
297
+ }
298
+ const application = String(body?.application || "").trim();
299
+ if (!application) {
300
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
301
+ return res.end(JSON.stringify({ error: "application_required" }));
302
+ }
303
+ const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
304
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
305
+ return res.end(JSON.stringify(job));
306
+ }
307
+
308
+ const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
309
+ if (method === "GET" && opsJobMatch) {
310
+ if (!await requireOpsBearer(req, res)) return;
311
+ const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
312
+ res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
313
+ return res.end(JSON.stringify(job || { error: "job_not_found" }));
314
+ }
315
+
316
+ const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
317
+ if (method === "GET" && opsJobEventsMatch) {
318
+ if (!await requireOpsBearer(req, res)) return;
319
+ const jobId = decodeURIComponent(opsJobEventsMatch[1]);
320
+ if (!opsJobs.get(jobId)) {
321
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
322
+ return res.end(JSON.stringify({ error: "job_not_found" }));
323
+ }
324
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
325
+ const unsubscribe = opsJobs.subscribe(jobId, (job) => {
326
+ if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
327
+ });
328
+ req.on("close", unsubscribe);
329
+ return;
330
+ }
331
+
332
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
333
+ res.end(JSON.stringify({ error: "not_found" }));
334
+ });
335
+ }