@lifeaitools/clauth 1.30.23 → 1.30.24

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 (40) hide show
  1. package/.clauth-skill/SKILL.md +111 -111
  2. package/README.md +25 -0
  3. package/cli/api.classify.test.js +75 -75
  4. package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
  5. package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
  6. package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
  7. package/cli/assets/watchdog.ps1 +42 -42
  8. package/cli/commands/agent-cron.js +396 -396
  9. package/cli/commands/agent-pool.js +1962 -1962
  10. package/cli/commands/codevelop.js +1190 -1190
  11. package/cli/commands/doctor.js +302 -302
  12. package/cli/commands/install.js +10 -10
  13. package/cli/commands/invite.js +175 -175
  14. package/cli/commands/join.js +179 -179
  15. package/cli/commands/npm.js +182 -182
  16. package/cli/commands/scrub.js +327 -327
  17. package/cli/commands/scrub.test.js +115 -115
  18. package/cli/commands/serve.js +41 -95
  19. package/cli/commands/watchdog.js +209 -209
  20. package/cli/conf-path.js +21 -21
  21. package/cli/enrollment-script.js +82 -82
  22. package/cli/fingerprint.js +143 -143
  23. package/cli/index.js +1053 -1053
  24. package/cli/lib/fs-git.js +282 -282
  25. package/cli/recovery.js +101 -101
  26. package/cli/studio-debug.js +1095 -1095
  27. package/cli/supervisor-registry.js +594 -589
  28. package/cli/supervisor-registry.test.js +397 -397
  29. package/cli/supervisor-ui.test.js +5 -83
  30. package/cli/watchdog-registry.js +209 -209
  31. package/cli/watchdog-registry.test.js +89 -89
  32. package/install.ps1 +21 -21
  33. package/package.json +2 -2
  34. package/scripts/bin/bootstrap-linux +0 -0
  35. package/scripts/bin/bootstrap-macos +0 -0
  36. package/scripts/bin/bootstrap-win.exe +0 -0
  37. package/supabase/migrations/001_clauth_schema.sql +12 -12
  38. package/supabase/migrations/003_clauth_config.sql +13 -13
  39. package/supabase/migrations/003_machine_enrollments.sql +39 -39
  40. package/cli/served-script-syntax.test.mjs +0 -54
@@ -1,589 +1,594 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { spawnSync } from "node:child_process";
6
-
7
- const SCHEMA = "lifeai.plugin.v1";
8
- const DEFAULT_TIMEOUT_MS = 3000;
9
- const DEFAULT_SUPERVISOR_PORT = 52439;
10
- const DESTINATIONS = new Set(["local/clauth/pm2", "vultr/clauth/pm2", "coolify/clauth/docker"]);
11
- const OWNERS = new Set(["clauth", "plugin", "external"]);
12
- const ACTIONS = new Set(["start", "stop", "restart", "reconcile", "test", "promote", "rollback"]);
13
- const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
14
- const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
15
- const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
16
- const DOCUMENTATION_FIELDS = ["architecture", "operator_guide", "install", "runbook", "tool_reference", "release", "agent_context"];
17
-
18
- export function getSupervisorPort() {
19
- return Number(process.env.CLAUTH_SUPERVISOR_PORT || DEFAULT_SUPERVISOR_PORT);
20
- }
21
-
22
- export function getSupervisorDir() {
23
- if (process.env.CLAUTH_SUPERVISOR_DIR) return process.env.CLAUTH_SUPERVISOR_DIR;
24
- const appdata = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
25
- return path.join(appdata, "clauth", "supervisor");
26
- }
27
-
28
- export function getClauthPm2Home() {
29
- if (process.env.CLAUTH_PM2_HOME) return process.env.CLAUTH_PM2_HOME;
30
- return path.join(getSupervisorDir(), "pm2-home");
31
- }
32
-
33
- function file(name) {
34
- return path.join(getSupervisorDir(), name);
35
- }
36
-
37
- function readJson(filePath, fallback) {
38
- try {
39
- if (!fs.existsSync(filePath)) return fallback;
40
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
41
- } catch {
42
- return fallback;
43
- }
44
- }
45
-
46
- function writeJson(filePath, value) {
47
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
49
- }
50
-
51
- function appendJsonl(filePath, value) {
52
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
- fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
54
- }
55
-
56
- function sha256(value) {
57
- return crypto.createHash("sha256").update(value).digest("hex");
58
- }
59
-
60
- function now() {
61
- return new Date().toISOString();
62
- }
63
-
64
- function healthUrlForSurface(surface) {
65
- if (!surface?.health) return null;
66
- if (/^https?:\/\//i.test(surface.health)) return surface.health;
67
- if (!surface.port || surface.port === "auto") return null;
68
- return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
69
- }
70
-
71
- function updateSurfaceState(surfaceId, patch) {
72
- const state = loadSupervisorState();
73
- state.surfaces = (state.surfaces || []).map((surface) => (
74
- `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
75
- ? { ...surface, ...patch }
76
- : surface
77
- ));
78
- saveSupervisorState(state);
79
- return state.surfaces.find((surface) => `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId) || null;
80
- }
81
-
82
- function appendSupervisorEvent(event) {
83
- appendJsonl(file("events.jsonl"), { ts: now(), ...event });
84
- }
85
-
86
- async function probeSurfaceHealth(url, fetchImpl, timeoutMs) {
87
- try {
88
- const controller = new AbortController();
89
- const timer = setTimeout(() => controller.abort(), timeoutMs);
90
- try {
91
- const response = await fetchImpl(url, { signal: controller.signal });
92
- return response?.ok
93
- ? { healthy: true, error: null }
94
- : { healthy: false, error: `HTTP ${response?.status ?? "unknown"}` };
95
- } finally {
96
- clearTimeout(timer);
97
- }
98
- } catch (err) {
99
- return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err) };
100
- }
101
- }
102
-
103
- /**
104
- * Probe enabled local clauth-owned surfaces and repair an unavailable one via
105
- * the same governed action path exposed to Dev Center. External/plugin-owned
106
- * surfaces are intentionally observe-only and are never restarted here.
107
- */
108
- export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
109
- const inspected = [];
110
- for (const surface of listSurfaces()) {
111
- if (!surface.enabled || surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
112
- const id = `${surface.plugin_id}:${surface.id}`;
113
- const url = healthUrlForSurface(surface);
114
- if (!url) continue;
115
- const observedAt = now();
116
- const health = await probeSurfaceHealth(url, fetchImpl, timeoutMs);
117
- const healthy = health.healthy;
118
- const error = health.error;
119
-
120
- if (healthy) {
121
- updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
122
- inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
123
- continue;
124
- }
125
-
126
- const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
127
- if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
128
- updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
129
- inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
130
- continue;
131
- }
132
-
133
- updateSurfaceState(id, {
134
- state: "unavailable",
135
- last_health_at: observedAt,
136
- last_health_ok: false,
137
- last_health_error: error,
138
- last_reconcile_at: observedAt,
139
- });
140
- appendSupervisorEvent({ kind: "surface_health_failed", surface_id: id, error, health: url });
141
- const receipt = runSurfaceAction(id, "reconcile", "supervisor-health-loop");
142
- const commandCompleted = receipt?.resulting_state?.ok === true;
143
- const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
144
- const repaired = commandCompleted && postHealth.healthy;
145
- updateSurfaceState(id, {
146
- state: repaired ? "current" : "unavailable",
147
- last_health_at: now(),
148
- last_health_ok: repaired,
149
- last_health_error: repaired ? null : postHealth.error,
150
- last_reconcile_operation_id: receipt?.operationId || null,
151
- });
152
- appendSupervisorEvent({ kind: repaired ? "surface_reconciled" : "surface_reconcile_failed", surface_id: id, operation_id: receipt?.operationId || null, command_completed: commandCompleted, health_ok: postHealth.healthy, error: postHealth.error || null });
153
- inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error: postHealth.error || error, operation_id: receipt?.operationId || null, observed_at: observedAt });
154
- }
155
- return { inspected };
156
- }
157
-
158
- function normalizeCommand(command, field) {
159
- if (!command) return [];
160
- if (!Array.isArray(command)) throw new Error(`${field} must be a command array`);
161
- if (command.length === 0) return [];
162
- const [cmd, ...args] = command;
163
- if (typeof cmd !== "string" || !cmd.trim()) throw new Error(`${field}[0] is required`);
164
- if (/[;&|<>]/.test(cmd)) throw new Error(`${field}[0] must be an executable path/name, not shell syntax`);
165
- return [cmd, ...args.map(String)];
166
- }
167
-
168
- function expandPathToken(value) {
169
- if (!value) return null;
170
- const root = process.env.REGEN_ROOT || process.env.LIFEAI_REPO_ROOT || "C:/Dev/regen-root";
171
- return String(value)
172
- .replace(/\$\{REGEN_ROOT\}/g, root)
173
- .replace(/\$REGEN_ROOT/g, root)
174
- .replace(/%REGEN_ROOT%/gi, root)
175
- .replace(/\$\{LIFEAI_REPO_ROOT\}/g, root)
176
- .replace(/\$LIFEAI_REPO_ROOT/g, root)
177
- .replace(/%LIFEAI_REPO_ROOT%/gi, root);
178
- }
179
-
180
- function normalizeLifecycleOwner(owner) {
181
- const value = owner || "clauth";
182
- if (!OWNERS.has(value)) throw new Error(`lifecycle_owner must be one of ${[...OWNERS].join(", ")}`);
183
- return value;
184
- }
185
-
186
- function normalizeDestination(destination) {
187
- const value = destination || "local/clauth/pm2";
188
- if (!DESTINATIONS.has(value)) throw new Error(`destination must be one of ${[...DESTINATIONS].join(", ")}`);
189
- return value;
190
- }
191
-
192
- function normalizeDocumentation(value) {
193
- if (value == null) return null;
194
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("documentation must be an object");
195
- const documentation = {};
196
- for (const field of DOCUMENTATION_FIELDS) {
197
- if (value[field] == null) continue;
198
- if (typeof value[field] !== "string" || !value[field].trim()) {
199
- throw new Error(`documentation.${field} must be a non-empty path`);
200
- }
201
- const ref = value[field].trim().replaceAll("\\", "/");
202
- if (ref.startsWith("/") || ref.includes("..") || /^[a-z]+:/i.test(ref)) {
203
- throw new Error(`documentation.${field} must be a repository-relative path`);
204
- }
205
- documentation[field] = ref;
206
- }
207
- if (!documentation.architecture) throw new Error("documentation.architecture is required");
208
- return documentation;
209
- }
210
-
211
- function localhostHealth(pathOrUrl, port) {
212
- if (!pathOrUrl) return null;
213
- if (/^https?:\/\//i.test(pathOrUrl)) {
214
- const url = new URL(pathOrUrl);
215
- if (!["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname)) {
216
- throw new Error("health URLs must be localhost-only");
217
- }
218
- return url.toString();
219
- }
220
- const safePath = String(pathOrUrl).startsWith("/") ? String(pathOrUrl) : `/${pathOrUrl}`;
221
- return port ? `http://127.0.0.1:${port}${safePath}` : safePath;
222
- }
223
-
224
- function normalizeSurface(surface, plugin) {
225
- if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
226
- const id = String(surface.id || "").trim();
227
- if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash");
228
- const destination = normalizeDestination(surface.destination || plugin.destination);
229
- const lifecycle_owner = normalizeLifecycleOwner(surface.lifecycle_owner || plugin.lifecycle_owner);
230
- const port = surface.port === undefined || surface.port === null || surface.port === "auto" ? surface.port ?? null : Number(surface.port);
231
- if (port !== null && port !== "auto" && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error("surface.port must be auto or a TCP port");
232
- return {
233
- id,
234
- plugin_id: plugin.id,
235
- name: surface.name || id,
236
- destination,
237
- lifecycle_owner,
238
- port,
239
- health: localhostHealth(surface.health || "/health", port === "auto" ? null : port),
240
- cwd: expandPathToken(surface.cwd || plugin.cwd),
241
- start: normalizeCommand(surface.start || plugin.start, "surface.start"),
242
- stop: normalizeCommand(surface.stop || plugin.stop, "surface.stop"),
243
- restart: normalizeCommand(surface.restart || plugin.restart, "surface.restart"),
244
- routes: Array.isArray(surface.routes) ? surface.routes : [],
245
- };
246
- }
247
-
248
- export function validatePluginManifest(manifest, sourcePath = "") {
249
- if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
250
- if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
251
- const id = String(manifest.id || "").trim();
252
- if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash");
253
- const version = String(manifest.version || "").trim();
254
- if (!version) throw new Error("version is required");
255
- const plugin = {
256
- schema: SCHEMA,
257
- id,
258
- version,
259
- publisher: String(manifest.publisher || "unknown"),
260
- documentation: normalizeDocumentation(manifest.documentation),
261
- // Only trusted managed manifests may opt into automatic startup. User
262
- // plugins remain awaiting_enable until an operator explicitly enables them.
263
- core: manifest.core === true,
264
- enable_default: manifest.enable_default === true,
265
- sourcePath,
266
- destination: normalizeDestination(manifest.destination),
267
- lifecycle_owner: normalizeLifecycleOwner(manifest.lifecycle_owner),
268
- credentials: Array.isArray(manifest.credentials) ? manifest.credentials.map((c) => ({
269
- name: String(c.name || c.id || "").trim(),
270
- key_type: String(c.key_type || c.type || "secret"),
271
- description: String(c.description || ""),
272
- required: c.required !== false,
273
- })).filter((c) => /^[a-zA-Z0-9_.-]+$/.test(c.name)) : [],
274
- surfaces: [],
275
- routes: Array.isArray(manifest.routes) ? manifest.routes : [],
276
- test: manifest.test && typeof manifest.test === "object" ? {
277
- command: normalizeCommand(manifest.test.command, "test.command"),
278
- port: manifest.test.port || "auto",
279
- health: manifest.test.health || "/health",
280
- selfTest: Array.isArray(manifest.test.selfTest) ? manifest.test.selfTest.map((cmd, i) => normalizeCommand(cmd, `test.selfTest[${i}]`)) : [],
281
- } : null,
282
- };
283
- plugin.surfaces = (Array.isArray(manifest.surfaces) ? manifest.surfaces : []).map((surface) => normalizeSurface(surface, plugin));
284
- return plugin;
285
- }
286
-
287
- function rootEntries() {
288
- const managed = (process.env.CLAUTH_MANAGED_PLUGIN_ROOTS || "").split(path.delimiter).filter(Boolean);
289
- const user = (process.env.CLAUTH_USER_PLUGIN_ROOTS || "").split(path.delimiter).filter(Boolean);
290
- if (managed.length === 0) managed.push(path.join(getSupervisorDir(), "managed-plugins"));
291
- if (user.length === 0) user.push(path.join(getSupervisorDir(), "user-plugins"));
292
- return [
293
- ...managed.map((root) => ({ root, source: "managed" })),
294
- ...user.map((root) => ({ root, source: "user" })),
295
- ];
296
- }
297
-
298
- function findManifestFiles(root) {
299
- const out = [];
300
- if (!fs.existsSync(root)) return out;
301
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
302
- const full = path.join(root, entry.name);
303
- if (entry.isDirectory()) {
304
- const candidate = path.join(full, "clauth-plugin.json");
305
- if (fs.existsSync(candidate)) out.push(candidate);
306
- } else if (entry.isFile() && entry.name === "clauth-plugin.json") {
307
- out.push(full);
308
- }
309
- }
310
- return out;
311
- }
312
-
313
- export function loadSupervisorState() {
314
- return readJson(file("state.json"), { plugins: [], surfaces: [], routes: [], observations: [], operations: [] });
315
- }
316
-
317
- function saveSupervisorState(state) {
318
- writeJson(file("state.json"), state);
319
- writeJson(file("last-known-good.json"), {
320
- signed_at: now(),
321
- hash: sha256(JSON.stringify(state)),
322
- state,
323
- });
324
- }
325
-
326
- function existingById(state) {
327
- return new Map((state.plugins || []).map((plugin) => [plugin.id, plugin]));
328
- }
329
-
330
- export function discoverPlugins() {
331
- const previous = loadSupervisorState();
332
- const seen = new Set();
333
- const byId = existingById(previous);
334
- const plugins = [];
335
- const surfaces = [];
336
- const events = [];
337
- const managedIds = new Set();
338
-
339
- for (const { root, source } of rootEntries()) {
340
- fs.mkdirSync(root, { recursive: true });
341
- for (const manifestPath of findManifestFiles(root)) {
342
- const raw = fs.readFileSync(manifestPath, "utf8");
343
- const hash = sha256(raw);
344
- try {
345
- const manifest = validatePluginManifest(JSON.parse(raw), manifestPath);
346
- if (source === "managed") managedIds.add(manifest.id);
347
- if (source === "user" && managedIds.has(manifest.id)) {
348
- throw new Error("user plugin cannot silently shadow a managed plugin id");
349
- }
350
- const prior = byId.get(manifest.id);
351
- const autoEnabled = source === "managed" && manifest.core === true && manifest.enable_default === true;
352
- const enabled = Boolean(prior?.enabled || autoEnabled);
353
- const state = enabled ? (prior?.manifest_hash === hash || autoEnabled ? "current" : "version_drift") : "awaiting_enable";
354
- const plugin = { ...manifest, source, discovery_root: root, manifest_hash: hash, state, enabled, discovered_at: now() };
355
- plugins.push(plugin);
356
- for (const surface of plugin.surfaces) surfaces.push({ ...surface, enabled: plugin.enabled, state });
357
- for (const credential of plugin.credentials) {
358
- events.push({ kind: "credential_required", plugin_id: plugin.id, credential, value_set: false });
359
- }
360
- seen.add(plugin.id);
361
- events.push({ kind: autoEnabled && !prior?.enabled ? "core_plugin_auto_enabled" : "plugin_discovered", plugin_id: plugin.id, state, source, manifest_hash: hash });
362
- } catch (error) {
363
- const id = path.basename(path.dirname(manifestPath));
364
- plugins.push({
365
- id,
366
- source,
367
- sourcePath: manifestPath,
368
- manifest_hash: hash,
369
- state: "manifest_invalid",
370
- enabled: false,
371
- error: error instanceof Error ? error.message : String(error),
372
- discovered_at: now(),
373
- });
374
- events.push({ kind: "plugin_quarantined", plugin_id: id, source, error: error instanceof Error ? error.message : String(error) });
375
- }
376
- }
377
- }
378
-
379
- for (const prior of previous.plugins || []) {
380
- if (!seen.has(prior.id) && prior.state !== "manifest_invalid") {
381
- plugins.push({ ...prior, enabled: false, state: prior.source === "managed" ? "missing_default" : "missing", missing_since: now() });
382
- events.push({ kind: "plugin_missing", plugin_id: prior.id, source: prior.source });
383
- }
384
- }
385
-
386
- const next = { ...previous, plugins, surfaces, routes: previous.routes || [], observations: previous.observations || [], operations: previous.operations || [] };
387
- saveSupervisorState(next);
388
- for (const event of events) appendJsonl(file("events.jsonl"), { ts: now(), ...event });
389
- return { plugins, surfaces, events };
390
- }
391
-
392
- export function listPlugins() {
393
- return loadSupervisorState().plugins || [];
394
- }
395
-
396
- export function listSurfaces() {
397
- return loadSupervisorState().surfaces || [];
398
- }
399
-
400
- export function listRoutes() {
401
- return loadSupervisorState().routes || [];
402
- }
403
-
404
- export function listTunnels() {
405
- return readJson(file("tunnels.json"), { tunnels: [] }).tunnels;
406
- }
407
-
408
- function writeTunnels(tunnels) {
409
- writeJson(file("tunnels.json"), { tunnels });
410
- }
411
-
412
- export function readSupervisorEvents(limit = 100) {
413
- const p = file("events.jsonl");
414
- if (!fs.existsSync(p)) return [];
415
- return fs.readFileSync(p, "utf8").split(/\r?\n/).filter(Boolean).slice(-limit).map((line) => {
416
- try { return JSON.parse(line); } catch { return { raw: line }; }
417
- });
418
- }
419
-
420
- function operation(action, target, prior, result, actor = "localhost") {
421
- const receipt = {
422
- operationId: crypto.randomUUID(),
423
- actor,
424
- action,
425
- target,
426
- prior_state: prior || null,
427
- resulting_state: result,
428
- evidence: result?.evidence || [],
429
- created_at: now(),
430
- completed_at: now(),
431
- };
432
- const state = loadSupervisorState();
433
- state.operations = [receipt, ...(state.operations || [])].slice(0, 500);
434
- saveSupervisorState(state);
435
- appendJsonl(file("events.jsonl"), { ts: now(), kind: "operation", ...receipt });
436
- return receipt;
437
- }
438
-
439
- export function setPluginEnabled(id, enabled, actor = "localhost") {
440
- const state = loadSupervisorState();
441
- const prior = (state.plugins || []).find((plugin) => plugin.id === id);
442
- if (!prior) return { error: "plugin_not_found" };
443
- if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
444
- const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
445
- state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
446
- state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
447
- saveSupervisorState(state);
448
- return operation(enabled ? "enable" : "disable", { plugin_id: id }, prior, nextPlugin, actor);
449
- }
450
-
451
- export function runPluginAction(id, action, actor = "localhost") {
452
- if (!["test", "promote"].includes(action)) return { error: "invalid_action" };
453
- const state = loadSupervisorState();
454
- const prior = (state.plugins || []).find((plugin) => plugin.id === id);
455
- if (!prior) return { error: "plugin_not_found" };
456
- if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
457
- const resultingState = action === "test" ? "candidate_testing" : "promotion_ready";
458
- const nextPlugin = {
459
- ...prior,
460
- state: resultingState,
461
- candidate: action === "test"
462
- ? {
463
- port: prior.test?.port === "auto" || !prior.test?.port ? 0 : prior.test.port,
464
- public_route: false,
465
- bind: "127.0.0.1",
466
- tested_at: now(),
467
- }
468
- : prior.candidate || null,
469
- };
470
- state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
471
- saveSupervisorState(state);
472
- return operation(action, { plugin_id: id }, prior, {
473
- ok: true,
474
- state: resultingState,
475
- private: true,
476
- public_route: false,
477
- evidence: ["plugin candidate context does not create a Cloudflare route"],
478
- }, actor);
479
- }
480
-
481
- function findSurface(id) {
482
- return (loadSupervisorState().surfaces || []).find((surface) => surface.id === id || `${surface.plugin_id}:${surface.id}` === id);
483
- }
484
-
485
- export function runSurfaceAction(id, action, actor = "localhost") {
486
- if (!ACTIONS.has(action)) return { error: "invalid_action" };
487
- const surface = findSurface(id);
488
- if (!surface) return { error: "surface_not_found" };
489
- if (surface.lifecycle_owner === "external") {
490
- return operation(action, { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "external_owner" }, actor);
491
- }
492
- if (surface.plugin_id === "codeflow" || surface.tags?.includes("codeflow")) {
493
- return operation(action, { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "codeflow_self_owned" }, actor);
494
- }
495
- if (action === "test") {
496
- const port = surface.port === "auto" || !surface.port ? 0 : surface.port;
497
- return operation(action, { surface_id: id }, surface, { ok: true, state: "candidate_testing", port, private: true, public_route: false, evidence: ["candidate surfaces bind localhost only"] }, actor);
498
- }
499
- if (surface.lifecycle_owner === "plugin") {
500
- return operation(action, { surface_id: id }, surface, { ok: true, state: "delegated_to_plugin_adapter", evidence: ["plugin lifecycle owner retained"] }, actor);
501
- }
502
- if (action === "promote" || action === "rollback") {
503
- return operation(action, { surface_id: id }, surface, {
504
- ok: false,
505
- state: "unsupported_surface_action",
506
- reason: `${action}_is_plugin_candidate_lifecycle`,
507
- evidence: ["surface action did not execute a process command"],
508
- }, actor);
509
- }
510
- let command = null;
511
- if (action === "stop") command = surface.stop;
512
- else if (action === "start") command = surface.start;
513
- else if (action === "restart") command = surface.restart;
514
- else if (action === "reconcile") command = surface.enabled === false ? surface.stop : (surface.restart || surface.start);
515
- if (!command || command.length === 0) {
516
- return operation(action, { surface_id: id }, surface, { ok: false, state: "command_missing" }, actor);
517
- }
518
- const execute = (selectedCommand) => {
519
- const [cmd, ...args] = selectedCommand;
520
- return spawnSync(cmd, args, {
521
- cwd: surface.cwd || undefined,
522
- env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
523
- windowsHide: true,
524
- encoding: "utf8",
525
- timeout: Number(surface.timeoutMs || 30000),
526
- });
527
- };
528
- let result = execute(command);
529
- let fallbackUsed = false;
530
- // PM2 restart returns non-zero when the process was deleted. Reconcile is
531
- // allowed to fall back to the declared start command; explicit restart keeps
532
- // its strict failure semantics for operator-requested actions.
533
- if (action === "reconcile" && result.status !== 0 && Array.isArray(surface.start) && surface.start.length > 0 && command !== surface.start) {
534
- const restartStatus = result.status;
535
- result = execute(surface.start);
536
- fallbackUsed = true;
537
- result.stderr = `restart exited ${restartStatus}; start fallback attempted\n${result.stderr || ""}`;
538
- }
539
- return operation(action, { surface_id: id }, surface, {
540
- ok: result.status === 0,
541
- state: result.status === 0 ? "operation_completed" : "operation_failed",
542
- status: result.status,
543
- stderr: result.stderr?.slice(0, 2000),
544
- evidence: [`CLAUTH_PM2_HOME=${getClauthPm2Home()}`, ...(fallbackUsed ? ["reconcile_start_fallback=true"] : [])],
545
- }, actor);
546
- }
547
-
548
- export function addTunnelRoute(tunnelId, route, actor = "localhost") {
549
- const tunnels = listTunnels();
550
- const tunnel = tunnels.find((item) => item.id === tunnelId);
551
- const prior = tunnel || { id: tunnelId, provider: "cloudflare", routes: [] };
552
- const routeId = String(route?.routeId || route?.id || crypto.randomUUID());
553
- const nextRoute = {
554
- id: routeId,
555
- hostname: String(route?.hostname || "").trim(),
556
- service_url: String(route?.service_url || route?.serviceUrl || "").trim(),
557
- desired_state: "enabled",
558
- public_route: true,
559
- };
560
- if (!nextRoute.hostname || !nextRoute.service_url) {
561
- return operation("tunnel_route_add", { tunnel_id: tunnelId }, prior, { ok: false, state: "invalid_route" }, actor);
562
- }
563
- const nextTunnel = { ...prior, routes: [nextRoute, ...(prior.routes || []).filter((item) => item.id !== routeId)] };
564
- writeTunnels([nextTunnel, ...tunnels.filter((item) => item.id !== tunnelId)]);
565
- return operation("tunnel_route_add", { tunnel_id: tunnelId, route_id: routeId }, prior, { ok: true, state: "route_recorded", route: nextRoute }, actor);
566
- }
567
-
568
- export function removeTunnelRoute(tunnelId, routeId, actor = "localhost") {
569
- const tunnels = listTunnels();
570
- const tunnel = tunnels.find((item) => item.id === tunnelId);
571
- if (!tunnel) return operation("tunnel_route_remove", { tunnel_id: tunnelId, route_id: routeId }, {}, { ok: false, state: "tunnel_not_found" }, actor);
572
- const prior = tunnel;
573
- const nextTunnel = { ...tunnel, routes: (tunnel.routes || []).filter((item) => item.id !== routeId) };
574
- writeTunnels([nextTunnel, ...tunnels.filter((item) => item.id !== tunnelId)]);
575
- return operation("tunnel_route_remove", { tunnel_id: tunnelId, route_id: routeId }, prior, { ok: true, state: "route_removed", route_id: routeId }, actor);
576
- }
577
-
578
- export function supervisorHealth() {
579
- const state = loadSupervisorState();
580
- return {
581
- status: "ok",
582
- port: getSupervisorPort(),
583
- schema: "clauth.supervisor.v1",
584
- pm2_home: getClauthPm2Home(),
585
- plugins: (state.plugins || []).length,
586
- surfaces: (state.surfaces || []).length,
587
- operations: (state.operations || []).length,
588
- };
589
- }
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const SCHEMA = "lifeai.plugin.v1";
8
+ const DEFAULT_TIMEOUT_MS = 3000;
9
+ const DEFAULT_SUPERVISOR_PORT = 52439;
10
+ // local/clauth/daemon: for a surface that is not a separately-startable
11
+ // process at all, but literally embedded in the clauth daemon itself (e.g.
12
+ // fs-mcp) distinct from local/clauth/pm2 (a separate local process clauth
13
+ // manages via pm2) because there is nothing for a surface action to
14
+ // start/stop/restart; the daemon's own lifecycle IS the surface's lifecycle.
15
+ const DESTINATIONS = new Set(["local/clauth/pm2", "local/clauth/daemon", "vultr/clauth/pm2", "coolify/clauth/docker"]);
16
+ const OWNERS = new Set(["clauth", "plugin", "external"]);
17
+ const ACTIONS = new Set(["start", "stop", "restart", "reconcile", "test", "promote", "rollback"]);
18
+ const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
19
+ const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
20
+ const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
21
+ const DOCUMENTATION_FIELDS = ["architecture", "operator_guide", "install", "runbook", "tool_reference", "release", "agent_context"];
22
+
23
+ export function getSupervisorPort() {
24
+ return Number(process.env.CLAUTH_SUPERVISOR_PORT || DEFAULT_SUPERVISOR_PORT);
25
+ }
26
+
27
+ export function getSupervisorDir() {
28
+ if (process.env.CLAUTH_SUPERVISOR_DIR) return process.env.CLAUTH_SUPERVISOR_DIR;
29
+ const appdata = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
30
+ return path.join(appdata, "clauth", "supervisor");
31
+ }
32
+
33
+ export function getClauthPm2Home() {
34
+ if (process.env.CLAUTH_PM2_HOME) return process.env.CLAUTH_PM2_HOME;
35
+ return path.join(getSupervisorDir(), "pm2-home");
36
+ }
37
+
38
+ function file(name) {
39
+ return path.join(getSupervisorDir(), name);
40
+ }
41
+
42
+ function readJson(filePath, fallback) {
43
+ try {
44
+ if (!fs.existsSync(filePath)) return fallback;
45
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
46
+ } catch {
47
+ return fallback;
48
+ }
49
+ }
50
+
51
+ function writeJson(filePath, value) {
52
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
54
+ }
55
+
56
+ function appendJsonl(filePath, value) {
57
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
58
+ fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
59
+ }
60
+
61
+ function sha256(value) {
62
+ return crypto.createHash("sha256").update(value).digest("hex");
63
+ }
64
+
65
+ function now() {
66
+ return new Date().toISOString();
67
+ }
68
+
69
+ function healthUrlForSurface(surface) {
70
+ if (!surface?.health) return null;
71
+ if (/^https?:\/\//i.test(surface.health)) return surface.health;
72
+ if (!surface.port || surface.port === "auto") return null;
73
+ return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
74
+ }
75
+
76
+ function updateSurfaceState(surfaceId, patch) {
77
+ const state = loadSupervisorState();
78
+ state.surfaces = (state.surfaces || []).map((surface) => (
79
+ `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
80
+ ? { ...surface, ...patch }
81
+ : surface
82
+ ));
83
+ saveSupervisorState(state);
84
+ return state.surfaces.find((surface) => `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId) || null;
85
+ }
86
+
87
+ function appendSupervisorEvent(event) {
88
+ appendJsonl(file("events.jsonl"), { ts: now(), ...event });
89
+ }
90
+
91
+ async function probeSurfaceHealth(url, fetchImpl, timeoutMs) {
92
+ try {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
95
+ try {
96
+ const response = await fetchImpl(url, { signal: controller.signal });
97
+ return response?.ok
98
+ ? { healthy: true, error: null }
99
+ : { healthy: false, error: `HTTP ${response?.status ?? "unknown"}` };
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ } catch (err) {
104
+ return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err) };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Probe enabled local clauth-owned surfaces and repair an unavailable one via
110
+ * the same governed action path exposed to Dev Center. External/plugin-owned
111
+ * surfaces are intentionally observe-only and are never restarted here.
112
+ */
113
+ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
114
+ const inspected = [];
115
+ for (const surface of listSurfaces()) {
116
+ if (!surface.enabled || surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
117
+ const id = `${surface.plugin_id}:${surface.id}`;
118
+ const url = healthUrlForSurface(surface);
119
+ if (!url) continue;
120
+ const observedAt = now();
121
+ const health = await probeSurfaceHealth(url, fetchImpl, timeoutMs);
122
+ const healthy = health.healthy;
123
+ const error = health.error;
124
+
125
+ if (healthy) {
126
+ updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
127
+ inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
128
+ continue;
129
+ }
130
+
131
+ const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
132
+ if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
133
+ updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
134
+ inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
135
+ continue;
136
+ }
137
+
138
+ updateSurfaceState(id, {
139
+ state: "unavailable",
140
+ last_health_at: observedAt,
141
+ last_health_ok: false,
142
+ last_health_error: error,
143
+ last_reconcile_at: observedAt,
144
+ });
145
+ appendSupervisorEvent({ kind: "surface_health_failed", surface_id: id, error, health: url });
146
+ const receipt = runSurfaceAction(id, "reconcile", "supervisor-health-loop");
147
+ const commandCompleted = receipt?.resulting_state?.ok === true;
148
+ const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
149
+ const repaired = commandCompleted && postHealth.healthy;
150
+ updateSurfaceState(id, {
151
+ state: repaired ? "current" : "unavailable",
152
+ last_health_at: now(),
153
+ last_health_ok: repaired,
154
+ last_health_error: repaired ? null : postHealth.error,
155
+ last_reconcile_operation_id: receipt?.operationId || null,
156
+ });
157
+ appendSupervisorEvent({ kind: repaired ? "surface_reconciled" : "surface_reconcile_failed", surface_id: id, operation_id: receipt?.operationId || null, command_completed: commandCompleted, health_ok: postHealth.healthy, error: postHealth.error || null });
158
+ inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error: postHealth.error || error, operation_id: receipt?.operationId || null, observed_at: observedAt });
159
+ }
160
+ return { inspected };
161
+ }
162
+
163
+ function normalizeCommand(command, field) {
164
+ if (!command) return [];
165
+ if (!Array.isArray(command)) throw new Error(`${field} must be a command array`);
166
+ if (command.length === 0) return [];
167
+ const [cmd, ...args] = command;
168
+ if (typeof cmd !== "string" || !cmd.trim()) throw new Error(`${field}[0] is required`);
169
+ if (/[;&|<>]/.test(cmd)) throw new Error(`${field}[0] must be an executable path/name, not shell syntax`);
170
+ return [cmd, ...args.map(String)];
171
+ }
172
+
173
+ function expandPathToken(value) {
174
+ if (!value) return null;
175
+ const root = process.env.REGEN_ROOT || process.env.LIFEAI_REPO_ROOT || "C:/Dev/regen-root";
176
+ return String(value)
177
+ .replace(/\$\{REGEN_ROOT\}/g, root)
178
+ .replace(/\$REGEN_ROOT/g, root)
179
+ .replace(/%REGEN_ROOT%/gi, root)
180
+ .replace(/\$\{LIFEAI_REPO_ROOT\}/g, root)
181
+ .replace(/\$LIFEAI_REPO_ROOT/g, root)
182
+ .replace(/%LIFEAI_REPO_ROOT%/gi, root);
183
+ }
184
+
185
+ function normalizeLifecycleOwner(owner) {
186
+ const value = owner || "clauth";
187
+ if (!OWNERS.has(value)) throw new Error(`lifecycle_owner must be one of ${[...OWNERS].join(", ")}`);
188
+ return value;
189
+ }
190
+
191
+ function normalizeDestination(destination) {
192
+ const value = destination || "local/clauth/pm2";
193
+ if (!DESTINATIONS.has(value)) throw new Error(`destination must be one of ${[...DESTINATIONS].join(", ")}`);
194
+ return value;
195
+ }
196
+
197
+ function normalizeDocumentation(value) {
198
+ if (value == null) return null;
199
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("documentation must be an object");
200
+ const documentation = {};
201
+ for (const field of DOCUMENTATION_FIELDS) {
202
+ if (value[field] == null) continue;
203
+ if (typeof value[field] !== "string" || !value[field].trim()) {
204
+ throw new Error(`documentation.${field} must be a non-empty path`);
205
+ }
206
+ const ref = value[field].trim().replaceAll("\\", "/");
207
+ if (ref.startsWith("/") || ref.includes("..") || /^[a-z]+:/i.test(ref)) {
208
+ throw new Error(`documentation.${field} must be a repository-relative path`);
209
+ }
210
+ documentation[field] = ref;
211
+ }
212
+ if (!documentation.architecture) throw new Error("documentation.architecture is required");
213
+ return documentation;
214
+ }
215
+
216
+ function localhostHealth(pathOrUrl, port) {
217
+ if (!pathOrUrl) return null;
218
+ if (/^https?:\/\//i.test(pathOrUrl)) {
219
+ const url = new URL(pathOrUrl);
220
+ if (!["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname)) {
221
+ throw new Error("health URLs must be localhost-only");
222
+ }
223
+ return url.toString();
224
+ }
225
+ const safePath = String(pathOrUrl).startsWith("/") ? String(pathOrUrl) : `/${pathOrUrl}`;
226
+ return port ? `http://127.0.0.1:${port}${safePath}` : safePath;
227
+ }
228
+
229
+ function normalizeSurface(surface, plugin) {
230
+ if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
231
+ const id = String(surface.id || "").trim();
232
+ if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash");
233
+ const destination = normalizeDestination(surface.destination || plugin.destination);
234
+ const lifecycle_owner = normalizeLifecycleOwner(surface.lifecycle_owner || plugin.lifecycle_owner);
235
+ const port = surface.port === undefined || surface.port === null || surface.port === "auto" ? surface.port ?? null : Number(surface.port);
236
+ if (port !== null && port !== "auto" && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error("surface.port must be auto or a TCP port");
237
+ return {
238
+ id,
239
+ plugin_id: plugin.id,
240
+ name: surface.name || id,
241
+ destination,
242
+ lifecycle_owner,
243
+ port,
244
+ health: localhostHealth(surface.health || "/health", port === "auto" ? null : port),
245
+ cwd: expandPathToken(surface.cwd || plugin.cwd),
246
+ start: normalizeCommand(surface.start || plugin.start, "surface.start"),
247
+ stop: normalizeCommand(surface.stop || plugin.stop, "surface.stop"),
248
+ restart: normalizeCommand(surface.restart || plugin.restart, "surface.restart"),
249
+ routes: Array.isArray(surface.routes) ? surface.routes : [],
250
+ };
251
+ }
252
+
253
+ export function validatePluginManifest(manifest, sourcePath = "") {
254
+ if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
255
+ if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
256
+ const id = String(manifest.id || "").trim();
257
+ if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash");
258
+ const version = String(manifest.version || "").trim();
259
+ if (!version) throw new Error("version is required");
260
+ const plugin = {
261
+ schema: SCHEMA,
262
+ id,
263
+ version,
264
+ publisher: String(manifest.publisher || "unknown"),
265
+ documentation: normalizeDocumentation(manifest.documentation),
266
+ // Only trusted managed manifests may opt into automatic startup. User
267
+ // plugins remain awaiting_enable until an operator explicitly enables them.
268
+ core: manifest.core === true,
269
+ enable_default: manifest.enable_default === true,
270
+ sourcePath,
271
+ destination: normalizeDestination(manifest.destination),
272
+ lifecycle_owner: normalizeLifecycleOwner(manifest.lifecycle_owner),
273
+ credentials: Array.isArray(manifest.credentials) ? manifest.credentials.map((c) => ({
274
+ name: String(c.name || c.id || "").trim(),
275
+ key_type: String(c.key_type || c.type || "secret"),
276
+ description: String(c.description || ""),
277
+ required: c.required !== false,
278
+ })).filter((c) => /^[a-zA-Z0-9_.-]+$/.test(c.name)) : [],
279
+ surfaces: [],
280
+ routes: Array.isArray(manifest.routes) ? manifest.routes : [],
281
+ test: manifest.test && typeof manifest.test === "object" ? {
282
+ command: normalizeCommand(manifest.test.command, "test.command"),
283
+ port: manifest.test.port || "auto",
284
+ health: manifest.test.health || "/health",
285
+ selfTest: Array.isArray(manifest.test.selfTest) ? manifest.test.selfTest.map((cmd, i) => normalizeCommand(cmd, `test.selfTest[${i}]`)) : [],
286
+ } : null,
287
+ };
288
+ plugin.surfaces = (Array.isArray(manifest.surfaces) ? manifest.surfaces : []).map((surface) => normalizeSurface(surface, plugin));
289
+ return plugin;
290
+ }
291
+
292
+ function rootEntries() {
293
+ const managed = (process.env.CLAUTH_MANAGED_PLUGIN_ROOTS || "").split(path.delimiter).filter(Boolean);
294
+ const user = (process.env.CLAUTH_USER_PLUGIN_ROOTS || "").split(path.delimiter).filter(Boolean);
295
+ if (managed.length === 0) managed.push(path.join(getSupervisorDir(), "managed-plugins"));
296
+ if (user.length === 0) user.push(path.join(getSupervisorDir(), "user-plugins"));
297
+ return [
298
+ ...managed.map((root) => ({ root, source: "managed" })),
299
+ ...user.map((root) => ({ root, source: "user" })),
300
+ ];
301
+ }
302
+
303
+ function findManifestFiles(root) {
304
+ const out = [];
305
+ if (!fs.existsSync(root)) return out;
306
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
307
+ const full = path.join(root, entry.name);
308
+ if (entry.isDirectory()) {
309
+ const candidate = path.join(full, "clauth-plugin.json");
310
+ if (fs.existsSync(candidate)) out.push(candidate);
311
+ } else if (entry.isFile() && entry.name === "clauth-plugin.json") {
312
+ out.push(full);
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
318
+ export function loadSupervisorState() {
319
+ return readJson(file("state.json"), { plugins: [], surfaces: [], routes: [], observations: [], operations: [] });
320
+ }
321
+
322
+ function saveSupervisorState(state) {
323
+ writeJson(file("state.json"), state);
324
+ writeJson(file("last-known-good.json"), {
325
+ signed_at: now(),
326
+ hash: sha256(JSON.stringify(state)),
327
+ state,
328
+ });
329
+ }
330
+
331
+ function existingById(state) {
332
+ return new Map((state.plugins || []).map((plugin) => [plugin.id, plugin]));
333
+ }
334
+
335
+ export function discoverPlugins() {
336
+ const previous = loadSupervisorState();
337
+ const seen = new Set();
338
+ const byId = existingById(previous);
339
+ const plugins = [];
340
+ const surfaces = [];
341
+ const events = [];
342
+ const managedIds = new Set();
343
+
344
+ for (const { root, source } of rootEntries()) {
345
+ fs.mkdirSync(root, { recursive: true });
346
+ for (const manifestPath of findManifestFiles(root)) {
347
+ const raw = fs.readFileSync(manifestPath, "utf8");
348
+ const hash = sha256(raw);
349
+ try {
350
+ const manifest = validatePluginManifest(JSON.parse(raw), manifestPath);
351
+ if (source === "managed") managedIds.add(manifest.id);
352
+ if (source === "user" && managedIds.has(manifest.id)) {
353
+ throw new Error("user plugin cannot silently shadow a managed plugin id");
354
+ }
355
+ const prior = byId.get(manifest.id);
356
+ const autoEnabled = source === "managed" && manifest.core === true && manifest.enable_default === true;
357
+ const enabled = Boolean(prior?.enabled || autoEnabled);
358
+ const state = enabled ? (prior?.manifest_hash === hash || autoEnabled ? "current" : "version_drift") : "awaiting_enable";
359
+ const plugin = { ...manifest, source, discovery_root: root, manifest_hash: hash, state, enabled, discovered_at: now() };
360
+ plugins.push(plugin);
361
+ for (const surface of plugin.surfaces) surfaces.push({ ...surface, enabled: plugin.enabled, state });
362
+ for (const credential of plugin.credentials) {
363
+ events.push({ kind: "credential_required", plugin_id: plugin.id, credential, value_set: false });
364
+ }
365
+ seen.add(plugin.id);
366
+ events.push({ kind: autoEnabled && !prior?.enabled ? "core_plugin_auto_enabled" : "plugin_discovered", plugin_id: plugin.id, state, source, manifest_hash: hash });
367
+ } catch (error) {
368
+ const id = path.basename(path.dirname(manifestPath));
369
+ plugins.push({
370
+ id,
371
+ source,
372
+ sourcePath: manifestPath,
373
+ manifest_hash: hash,
374
+ state: "manifest_invalid",
375
+ enabled: false,
376
+ error: error instanceof Error ? error.message : String(error),
377
+ discovered_at: now(),
378
+ });
379
+ events.push({ kind: "plugin_quarantined", plugin_id: id, source, error: error instanceof Error ? error.message : String(error) });
380
+ }
381
+ }
382
+ }
383
+
384
+ for (const prior of previous.plugins || []) {
385
+ if (!seen.has(prior.id) && prior.state !== "manifest_invalid") {
386
+ plugins.push({ ...prior, enabled: false, state: prior.source === "managed" ? "missing_default" : "missing", missing_since: now() });
387
+ events.push({ kind: "plugin_missing", plugin_id: prior.id, source: prior.source });
388
+ }
389
+ }
390
+
391
+ const next = { ...previous, plugins, surfaces, routes: previous.routes || [], observations: previous.observations || [], operations: previous.operations || [] };
392
+ saveSupervisorState(next);
393
+ for (const event of events) appendJsonl(file("events.jsonl"), { ts: now(), ...event });
394
+ return { plugins, surfaces, events };
395
+ }
396
+
397
+ export function listPlugins() {
398
+ return loadSupervisorState().plugins || [];
399
+ }
400
+
401
+ export function listSurfaces() {
402
+ return loadSupervisorState().surfaces || [];
403
+ }
404
+
405
+ export function listRoutes() {
406
+ return loadSupervisorState().routes || [];
407
+ }
408
+
409
+ export function listTunnels() {
410
+ return readJson(file("tunnels.json"), { tunnels: [] }).tunnels;
411
+ }
412
+
413
+ function writeTunnels(tunnels) {
414
+ writeJson(file("tunnels.json"), { tunnels });
415
+ }
416
+
417
+ export function readSupervisorEvents(limit = 100) {
418
+ const p = file("events.jsonl");
419
+ if (!fs.existsSync(p)) return [];
420
+ return fs.readFileSync(p, "utf8").split(/\r?\n/).filter(Boolean).slice(-limit).map((line) => {
421
+ try { return JSON.parse(line); } catch { return { raw: line }; }
422
+ });
423
+ }
424
+
425
+ function operation(action, target, prior, result, actor = "localhost") {
426
+ const receipt = {
427
+ operationId: crypto.randomUUID(),
428
+ actor,
429
+ action,
430
+ target,
431
+ prior_state: prior || null,
432
+ resulting_state: result,
433
+ evidence: result?.evidence || [],
434
+ created_at: now(),
435
+ completed_at: now(),
436
+ };
437
+ const state = loadSupervisorState();
438
+ state.operations = [receipt, ...(state.operations || [])].slice(0, 500);
439
+ saveSupervisorState(state);
440
+ appendJsonl(file("events.jsonl"), { ts: now(), kind: "operation", ...receipt });
441
+ return receipt;
442
+ }
443
+
444
+ export function setPluginEnabled(id, enabled, actor = "localhost") {
445
+ const state = loadSupervisorState();
446
+ const prior = (state.plugins || []).find((plugin) => plugin.id === id);
447
+ if (!prior) return { error: "plugin_not_found" };
448
+ if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
449
+ const nextPlugin = { ...prior, enabled: Boolean(enabled), state: enabled ? "current" : "awaiting_enable" };
450
+ state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
451
+ state.surfaces = (state.surfaces || []).map((surface) => surface.plugin_id === id ? { ...surface, enabled: Boolean(enabled), state: nextPlugin.state } : surface);
452
+ saveSupervisorState(state);
453
+ return operation(enabled ? "enable" : "disable", { plugin_id: id }, prior, nextPlugin, actor);
454
+ }
455
+
456
+ export function runPluginAction(id, action, actor = "localhost") {
457
+ if (!["test", "promote"].includes(action)) return { error: "invalid_action" };
458
+ const state = loadSupervisorState();
459
+ const prior = (state.plugins || []).find((plugin) => plugin.id === id);
460
+ if (!prior) return { error: "plugin_not_found" };
461
+ if (prior.state === "manifest_invalid") return { error: "manifest_invalid" };
462
+ const resultingState = action === "test" ? "candidate_testing" : "promotion_ready";
463
+ const nextPlugin = {
464
+ ...prior,
465
+ state: resultingState,
466
+ candidate: action === "test"
467
+ ? {
468
+ port: prior.test?.port === "auto" || !prior.test?.port ? 0 : prior.test.port,
469
+ public_route: false,
470
+ bind: "127.0.0.1",
471
+ tested_at: now(),
472
+ }
473
+ : prior.candidate || null,
474
+ };
475
+ state.plugins = state.plugins.map((plugin) => plugin.id === id ? nextPlugin : plugin);
476
+ saveSupervisorState(state);
477
+ return operation(action, { plugin_id: id }, prior, {
478
+ ok: true,
479
+ state: resultingState,
480
+ private: true,
481
+ public_route: false,
482
+ evidence: ["plugin candidate context does not create a Cloudflare route"],
483
+ }, actor);
484
+ }
485
+
486
+ function findSurface(id) {
487
+ return (loadSupervisorState().surfaces || []).find((surface) => surface.id === id || `${surface.plugin_id}:${surface.id}` === id);
488
+ }
489
+
490
+ export function runSurfaceAction(id, action, actor = "localhost") {
491
+ if (!ACTIONS.has(action)) return { error: "invalid_action" };
492
+ const surface = findSurface(id);
493
+ if (!surface) return { error: "surface_not_found" };
494
+ if (surface.lifecycle_owner === "external") {
495
+ return operation(action, { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "external_owner" }, actor);
496
+ }
497
+ if (surface.plugin_id === "codeflow" || surface.tags?.includes("codeflow")) {
498
+ return operation(action, { surface_id: id }, surface, { ok: false, state: "observed_only", reason: "codeflow_self_owned" }, actor);
499
+ }
500
+ if (action === "test") {
501
+ const port = surface.port === "auto" || !surface.port ? 0 : surface.port;
502
+ return operation(action, { surface_id: id }, surface, { ok: true, state: "candidate_testing", port, private: true, public_route: false, evidence: ["candidate surfaces bind localhost only"] }, actor);
503
+ }
504
+ if (surface.lifecycle_owner === "plugin") {
505
+ return operation(action, { surface_id: id }, surface, { ok: true, state: "delegated_to_plugin_adapter", evidence: ["plugin lifecycle owner retained"] }, actor);
506
+ }
507
+ if (action === "promote" || action === "rollback") {
508
+ return operation(action, { surface_id: id }, surface, {
509
+ ok: false,
510
+ state: "unsupported_surface_action",
511
+ reason: `${action}_is_plugin_candidate_lifecycle`,
512
+ evidence: ["surface action did not execute a process command"],
513
+ }, actor);
514
+ }
515
+ let command = null;
516
+ if (action === "stop") command = surface.stop;
517
+ else if (action === "start") command = surface.start;
518
+ else if (action === "restart") command = surface.restart;
519
+ else if (action === "reconcile") command = surface.enabled === false ? surface.stop : (surface.restart || surface.start);
520
+ if (!command || command.length === 0) {
521
+ return operation(action, { surface_id: id }, surface, { ok: false, state: "command_missing" }, actor);
522
+ }
523
+ const execute = (selectedCommand) => {
524
+ const [cmd, ...args] = selectedCommand;
525
+ return spawnSync(cmd, args, {
526
+ cwd: surface.cwd || undefined,
527
+ env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
528
+ windowsHide: true,
529
+ encoding: "utf8",
530
+ timeout: Number(surface.timeoutMs || 30000),
531
+ });
532
+ };
533
+ let result = execute(command);
534
+ let fallbackUsed = false;
535
+ // PM2 restart returns non-zero when the process was deleted. Reconcile is
536
+ // allowed to fall back to the declared start command; explicit restart keeps
537
+ // its strict failure semantics for operator-requested actions.
538
+ if (action === "reconcile" && result.status !== 0 && Array.isArray(surface.start) && surface.start.length > 0 && command !== surface.start) {
539
+ const restartStatus = result.status;
540
+ result = execute(surface.start);
541
+ fallbackUsed = true;
542
+ result.stderr = `restart exited ${restartStatus}; start fallback attempted\n${result.stderr || ""}`;
543
+ }
544
+ return operation(action, { surface_id: id }, surface, {
545
+ ok: result.status === 0,
546
+ state: result.status === 0 ? "operation_completed" : "operation_failed",
547
+ status: result.status,
548
+ stderr: result.stderr?.slice(0, 2000),
549
+ evidence: [`CLAUTH_PM2_HOME=${getClauthPm2Home()}`, ...(fallbackUsed ? ["reconcile_start_fallback=true"] : [])],
550
+ }, actor);
551
+ }
552
+
553
+ export function addTunnelRoute(tunnelId, route, actor = "localhost") {
554
+ const tunnels = listTunnels();
555
+ const tunnel = tunnels.find((item) => item.id === tunnelId);
556
+ const prior = tunnel || { id: tunnelId, provider: "cloudflare", routes: [] };
557
+ const routeId = String(route?.routeId || route?.id || crypto.randomUUID());
558
+ const nextRoute = {
559
+ id: routeId,
560
+ hostname: String(route?.hostname || "").trim(),
561
+ service_url: String(route?.service_url || route?.serviceUrl || "").trim(),
562
+ desired_state: "enabled",
563
+ public_route: true,
564
+ };
565
+ if (!nextRoute.hostname || !nextRoute.service_url) {
566
+ return operation("tunnel_route_add", { tunnel_id: tunnelId }, prior, { ok: false, state: "invalid_route" }, actor);
567
+ }
568
+ const nextTunnel = { ...prior, routes: [nextRoute, ...(prior.routes || []).filter((item) => item.id !== routeId)] };
569
+ writeTunnels([nextTunnel, ...tunnels.filter((item) => item.id !== tunnelId)]);
570
+ return operation("tunnel_route_add", { tunnel_id: tunnelId, route_id: routeId }, prior, { ok: true, state: "route_recorded", route: nextRoute }, actor);
571
+ }
572
+
573
+ export function removeTunnelRoute(tunnelId, routeId, actor = "localhost") {
574
+ const tunnels = listTunnels();
575
+ const tunnel = tunnels.find((item) => item.id === tunnelId);
576
+ if (!tunnel) return operation("tunnel_route_remove", { tunnel_id: tunnelId, route_id: routeId }, {}, { ok: false, state: "tunnel_not_found" }, actor);
577
+ const prior = tunnel;
578
+ const nextTunnel = { ...tunnel, routes: (tunnel.routes || []).filter((item) => item.id !== routeId) };
579
+ writeTunnels([nextTunnel, ...tunnels.filter((item) => item.id !== tunnelId)]);
580
+ return operation("tunnel_route_remove", { tunnel_id: tunnelId, route_id: routeId }, prior, { ok: true, state: "route_removed", route_id: routeId }, actor);
581
+ }
582
+
583
+ export function supervisorHealth() {
584
+ const state = loadSupervisorState();
585
+ return {
586
+ status: "ok",
587
+ port: getSupervisorPort(),
588
+ schema: "clauth.supervisor.v1",
589
+ pm2_home: getClauthPm2Home(),
590
+ plugins: (state.plugins || []).length,
591
+ surfaces: (state.surfaces || []).length,
592
+ operations: (state.operations || []).length,
593
+ };
594
+ }