@lifeaitools/clauth 1.30.14 → 1.30.15

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.
@@ -41,8 +41,9 @@ import {
41
41
  listTunnels,
42
42
  readSupervisorEvents,
43
43
  addTunnelRoute,
44
- removeTunnelRoute,
45
- runPluginAction,
44
+ removeTunnelRoute,
45
+ reconcileSurfaceHealth,
46
+ runPluginAction,
46
47
  runSurfaceAction,
47
48
  setPluginEnabled,
48
49
  supervisorHealth,
@@ -7819,7 +7820,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7819
7820
 
7820
7821
  // If vault is already unlocked at startup (auto-unlock from boot.key skips POST /unlock),
7821
7822
  // mirror the tunnel-start logic that the unlock handler would have run.
7822
- if (initPassword) {
7823
+ if (initPassword) {
7823
7824
  setImmediate(() => {
7824
7825
  if (tunnelHostname) {
7825
7826
  tunnelStatus = "starting";
@@ -7835,11 +7836,32 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
7835
7836
  }
7836
7837
  }).catch(() => { tunnelStatus = "error"; });
7837
7838
  }
7838
- });
7839
- }
7840
-
7841
- return server;
7842
- }
7839
+ });
7840
+ }
7841
+
7842
+ // The localhost supervisor is the only process allowed to repair clauth-owned
7843
+ // local surfaces. Keep this loop out of the vault/staged instances and make
7844
+ // the cadence configurable for deterministic tests.
7845
+ if (port === getSupervisorPort() && process.env.CLAUTH_SUPERVISOR_HEALTH_RECONCILE !== "0") {
7846
+ const configuredInterval = Number(process.env.CLAUTH_SUPERVISOR_HEALTH_INTERVAL_MS || 10000);
7847
+ const intervalMs = Number.isFinite(configuredInterval) ? Math.max(1000, Math.min(configuredInterval, 300000)) : 10000;
7848
+ let healthReconcileInFlight = false;
7849
+ const runHealthReconcile = () => {
7850
+ if (healthReconcileInFlight) return;
7851
+ healthReconcileInFlight = true;
7852
+ reconcileSurfaceHealth().catch((err) => {
7853
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] supervisor health reconcile failed: ${err.message}\n`); } catch {}
7854
+ }).finally(() => { healthReconcileInFlight = false; });
7855
+ };
7856
+ const healthTimer = setInterval(runHealthReconcile, intervalMs);
7857
+ healthTimer.unref?.();
7858
+ server.__supervisorHealthTimer = healthTimer;
7859
+ server.on("close", () => clearInterval(healthTimer));
7860
+ setImmediate(runHealthReconcile);
7861
+ }
7862
+
7863
+ return server;
7864
+ }
7843
7865
 
7844
7866
  // ── Actions ──────────────────────────────────────────────────
7845
7867
 
@@ -10,6 +10,9 @@ const DEFAULT_SUPERVISOR_PORT = 52439;
10
10
  const DESTINATIONS = new Set(["local/clauth/pm2", "vultr/clauth/pm2", "coolify/clauth/docker"]);
11
11
  const OWNERS = new Set(["clauth", "plugin", "external"]);
12
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;
13
16
 
14
17
  export function getSupervisorPort() {
15
18
  return Number(process.env.CLAUTH_SUPERVISOR_PORT || DEFAULT_SUPERVISOR_PORT);
@@ -57,6 +60,86 @@ function now() {
57
60
  return new Date().toISOString();
58
61
  }
59
62
 
63
+ function healthUrlForSurface(surface) {
64
+ if (!surface?.health) return null;
65
+ if (/^https?:\/\//i.test(surface.health)) return surface.health;
66
+ if (!surface.port || surface.port === "auto") return null;
67
+ return `http://127.0.0.1:${surface.port}${String(surface.health).startsWith("/") ? surface.health : `/${surface.health}`}`;
68
+ }
69
+
70
+ function updateSurfaceState(surfaceId, patch) {
71
+ const state = loadSupervisorState();
72
+ state.surfaces = (state.surfaces || []).map((surface) => (
73
+ `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId
74
+ ? { ...surface, ...patch }
75
+ : surface
76
+ ));
77
+ saveSupervisorState(state);
78
+ return state.surfaces.find((surface) => `${surface.plugin_id}:${surface.id}` === surfaceId || surface.id === surfaceId) || null;
79
+ }
80
+
81
+ function appendSupervisorEvent(event) {
82
+ appendJsonl(file("events.jsonl"), { ts: now(), ...event });
83
+ }
84
+
85
+ /**
86
+ * Probe enabled local clauth-owned surfaces and repair an unavailable one via
87
+ * the same governed action path exposed to Dev Center. External/plugin-owned
88
+ * surfaces are intentionally observe-only and are never restarted here.
89
+ */
90
+ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
91
+ const inspected = [];
92
+ for (const surface of listSurfaces()) {
93
+ if (!surface.enabled || surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
94
+ const id = `${surface.plugin_id}:${surface.id}`;
95
+ const url = healthUrlForSurface(surface);
96
+ if (!url) continue;
97
+ const observedAt = now();
98
+ let healthy = false;
99
+ let error = null;
100
+ try {
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
103
+ try {
104
+ const response = await fetchImpl(url, { signal: controller.signal });
105
+ healthy = Boolean(response?.ok);
106
+ if (!healthy) error = `HTTP ${response?.status ?? "unknown"}`;
107
+ } finally {
108
+ clearTimeout(timer);
109
+ }
110
+ } catch (err) {
111
+ error = err?.name === "AbortError" ? "health timeout" : String(err?.message || err);
112
+ }
113
+
114
+ if (healthy) {
115
+ updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
116
+ inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
117
+ continue;
118
+ }
119
+
120
+ const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
121
+ if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
122
+ updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
123
+ inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
124
+ continue;
125
+ }
126
+
127
+ updateSurfaceState(id, {
128
+ state: "unavailable",
129
+ last_health_at: observedAt,
130
+ last_health_ok: false,
131
+ last_health_error: error,
132
+ last_reconcile_at: observedAt,
133
+ });
134
+ appendSupervisorEvent({ kind: "surface_health_failed", surface_id: id, error, health: url });
135
+ const receipt = runSurfaceAction(id, "reconcile", "supervisor-health-loop");
136
+ const repaired = receipt?.resulting_state?.ok === true;
137
+ updateSurfaceState(id, { state: repaired ? "current" : "unavailable", last_reconcile_operation_id: receipt?.operationId || null });
138
+ inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error, operation_id: receipt?.operationId || null, observed_at: observedAt });
139
+ }
140
+ return { inspected };
141
+ }
142
+
60
143
  function normalizeCommand(command, field) {
61
144
  if (!command) return [];
62
145
  if (!Array.isArray(command)) throw new Error(`${field} must be a command array`);
@@ -10,6 +10,7 @@ import {
10
10
  getClauthPm2Home,
11
11
  listPlugins,
12
12
  listSurfaces,
13
+ reconcileSurfaceHealth,
13
14
  runPluginAction,
14
15
  runSurfaceAction,
15
16
  removeTunnelRoute,
@@ -195,6 +196,78 @@ test("surface promote and rollback never fall through to restart commands", () =
195
196
  }
196
197
  }));
197
198
 
199
+ test("health reconciliation marks a failed clauth surface and repairs it through reconcile", async () => {
200
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-health-"));
201
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
202
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
203
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
204
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
205
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
206
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
207
+ try {
208
+ writePlugin(root, "managed", "health-demo", baseManifest("health-demo", {
209
+ core: true,
210
+ enable_default: true,
211
+ surfaces: [{
212
+ id: "primary",
213
+ destination: "local/clauth/pm2",
214
+ lifecycle_owner: "clauth",
215
+ port: 39111,
216
+ health: "/health",
217
+ restart: [process.execPath, "--version"],
218
+ }],
219
+ }));
220
+ discoverPlugins();
221
+ const result = await reconcileSurfaceHealth({
222
+ fetchImpl: async () => ({ ok: false, status: 503 }),
223
+ });
224
+ assert.equal(result.inspected[0].surface_id, "health-demo:primary");
225
+ assert.equal(result.inspected[0].state, "reconciled");
226
+ const surface = listSurfaces().find((item) => item.plugin_id === "health-demo");
227
+ assert.equal(surface.state, "current");
228
+ assert.ok(surface.last_reconcile_operation_id);
229
+ } finally {
230
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
231
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
232
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
233
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
234
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
235
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
236
+ fs.rmSync(root, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ test("health reconciliation never restarts external or plugin-owned surfaces", async () => {
241
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-observe-"));
242
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
243
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
244
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
245
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
246
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
247
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
248
+ try {
249
+ writePlugin(root, "managed", "external-demo", baseManifest("external-demo", {
250
+ core: true,
251
+ enable_default: true,
252
+ destination: "vultr/clauth/pm2",
253
+ lifecycle_owner: "external",
254
+ surfaces: [{ id: "primary", destination: "vultr/clauth/pm2", lifecycle_owner: "external", port: 39112, health: "/health" }],
255
+ }));
256
+ discoverPlugins();
257
+ const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
258
+ assert.deepEqual(result.inspected, []);
259
+ assert.equal(listSurfaces()[0].state, "current");
260
+ } finally {
261
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
262
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
263
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
264
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
265
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
266
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
267
+ fs.rmSync(root, { recursive: true, force: true });
268
+ }
269
+ });
270
+
198
271
  test("supervisor write-token policy is temporarily relaxed only for the localhost supervisor port", () => {
199
272
  assert.equal(supervisorRequiresWriteToken(52439, {}), false);
200
273
  assert.equal(supervisorRequiresWriteToken(52439, { CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN: "1" }), true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.30.14",
3
+ "version": "1.30.15",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {