@lifeaitools/clauth 1.30.14 → 1.30.16

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,100 @@ 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
+ async function probeSurfaceHealth(url, fetchImpl, timeoutMs) {
86
+ try {
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
89
+ try {
90
+ const response = await fetchImpl(url, { signal: controller.signal });
91
+ return response?.ok
92
+ ? { healthy: true, error: null }
93
+ : { healthy: false, error: `HTTP ${response?.status ?? "unknown"}` };
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }
97
+ } catch (err) {
98
+ return { healthy: false, error: err?.name === "AbortError" ? "health timeout" : String(err?.message || err) };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Probe enabled local clauth-owned surfaces and repair an unavailable one via
104
+ * the same governed action path exposed to Dev Center. External/plugin-owned
105
+ * surfaces are intentionally observe-only and are never restarted here.
106
+ */
107
+ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
108
+ const inspected = [];
109
+ for (const surface of listSurfaces()) {
110
+ if (!surface.enabled || surface.lifecycle_owner !== "clauth" || surface.destination !== "local/clauth/pm2") continue;
111
+ const id = `${surface.plugin_id}:${surface.id}`;
112
+ const url = healthUrlForSurface(surface);
113
+ if (!url) continue;
114
+ const observedAt = now();
115
+ const health = await probeSurfaceHealth(url, fetchImpl, timeoutMs);
116
+ const healthy = health.healthy;
117
+ const error = health.error;
118
+
119
+ if (healthy) {
120
+ updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
121
+ inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
122
+ continue;
123
+ }
124
+
125
+ const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
126
+ if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
127
+ updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
128
+ inspected.push({ surface_id: id, state: "unavailable", error, cooldown: true, observed_at: observedAt });
129
+ continue;
130
+ }
131
+
132
+ updateSurfaceState(id, {
133
+ state: "unavailable",
134
+ last_health_at: observedAt,
135
+ last_health_ok: false,
136
+ last_health_error: error,
137
+ last_reconcile_at: observedAt,
138
+ });
139
+ appendSupervisorEvent({ kind: "surface_health_failed", surface_id: id, error, health: url });
140
+ const receipt = runSurfaceAction(id, "reconcile", "supervisor-health-loop");
141
+ const commandCompleted = receipt?.resulting_state?.ok === true;
142
+ const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
143
+ const repaired = commandCompleted && postHealth.healthy;
144
+ updateSurfaceState(id, {
145
+ state: repaired ? "current" : "unavailable",
146
+ last_health_at: now(),
147
+ last_health_ok: repaired,
148
+ last_health_error: repaired ? null : postHealth.error,
149
+ last_reconcile_operation_id: receipt?.operationId || null,
150
+ });
151
+ 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 });
152
+ inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error: postHealth.error || error, operation_id: receipt?.operationId || null, observed_at: observedAt });
153
+ }
154
+ return { inspected };
155
+ }
156
+
60
157
  function normalizeCommand(command, field) {
61
158
  if (!command) return [];
62
159
  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,110 @@ 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
+ let healthCalls = 0;
222
+ const result = await reconcileSurfaceHealth({
223
+ fetchImpl: async () => ({ ok: ++healthCalls > 1, status: 503 }),
224
+ });
225
+ assert.equal(result.inspected[0].surface_id, "health-demo:primary");
226
+ assert.equal(result.inspected[0].state, "reconciled");
227
+ const surface = listSurfaces().find((item) => item.plugin_id === "health-demo");
228
+ assert.equal(surface.state, "current");
229
+ assert.ok(surface.last_reconcile_operation_id);
230
+ assert.equal(healthCalls, 2);
231
+ } finally {
232
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
233
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
234
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
235
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
236
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
237
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
238
+ fs.rmSync(root, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ test("health reconciliation does not claim current when a successful command leaves health down", async () => {
243
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-post-health-"));
244
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
245
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
246
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
247
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
248
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
249
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
250
+ try {
251
+ writePlugin(root, "managed", "post-health-demo", baseManifest("post-health-demo", {
252
+ core: true,
253
+ enable_default: true,
254
+ surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "clauth", port: 39113, health: "/health", restart: [process.execPath, "--version"] }],
255
+ }));
256
+ discoverPlugins();
257
+ const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
258
+ assert.equal(result.inspected[0].state, "reconcile_failed");
259
+ assert.equal(listSurfaces()[0].state, "unavailable");
260
+ assert.equal(listSurfaces()[0].last_health_ok, false);
261
+ } finally {
262
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
263
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
264
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
265
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
266
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
267
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
268
+ fs.rmSync(root, { recursive: true, force: true });
269
+ }
270
+ });
271
+
272
+ test("health reconciliation never restarts external or plugin-owned surfaces", async () => {
273
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-observe-"));
274
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
275
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
276
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
277
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
278
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
279
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
280
+ try {
281
+ writePlugin(root, "managed", "external-demo", baseManifest("external-demo", {
282
+ core: true,
283
+ enable_default: true,
284
+ destination: "vultr/clauth/pm2",
285
+ lifecycle_owner: "external",
286
+ surfaces: [{ id: "primary", destination: "vultr/clauth/pm2", lifecycle_owner: "external", port: 39112, health: "/health" }],
287
+ }));
288
+ discoverPlugins();
289
+ const result = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
290
+ assert.deepEqual(result.inspected, []);
291
+ assert.equal(listSurfaces()[0].state, "current");
292
+ } finally {
293
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
294
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
295
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
296
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
297
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
298
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
299
+ fs.rmSync(root, { recursive: true, force: true });
300
+ }
301
+ });
302
+
198
303
  test("supervisor write-token policy is temporarily relaxed only for the localhost supervisor port", () => {
199
304
  assert.equal(supervisorRequiresWriteToken(52439, {}), false);
200
305
  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.16",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {