@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,106 @@
1
+ // cli/services/terminal-window.js
2
+ // Focuses or opens a visible Windows window for a terminal session (either
3
+ // the session's own active Claude process window, or a PowerShell inspector
4
+ // window showing the session's metadata). Split out of terminal-sessions.js
5
+ // -- the SOLID scorer flagged that file's original shape as mixing this
6
+ // Windows-window-control concern with shared-registry state, session
7
+ // lifecycle, and message dispatch.
8
+
9
+ import os from "node:os";
10
+ import { spawn as spawnProc, spawnSync } from "node:child_process";
11
+ import { terminalSessions } from "./terminal-registry.js";
12
+
13
+ export function showTerminalSession(session_id) {
14
+ const session = terminalSessions.get(session_id);
15
+ if (!session) return { error: 'not_found', message: `Session ${session_id} not found` };
16
+
17
+ const pid = session.activeProc?.pid || null;
18
+ if (process.platform === "win32" && pid) {
19
+ try {
20
+ const script = [
21
+ "Add-Type @'",
22
+ "using System;",
23
+ "using System.Runtime.InteropServices;",
24
+ "public class Win32ShowWindow {",
25
+ " [DllImport(\"user32.dll\")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);",
26
+ " [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);",
27
+ "}",
28
+ "'@",
29
+ `$p = Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue`,
30
+ "if ($p -and $p.MainWindowHandle -ne 0) {",
31
+ " [Win32ShowWindow]::ShowWindowAsync($p.MainWindowHandle, 5) | Out-Null",
32
+ " [Win32ShowWindow]::SetForegroundWindow($p.MainWindowHandle) | Out-Null",
33
+ " 'shown'",
34
+ "} else {",
35
+ " 'no-window'",
36
+ "}",
37
+ ].join("\n");
38
+ const result = spawnSync("powershell.exe", [
39
+ "-NoProfile",
40
+ "-ExecutionPolicy",
41
+ "Bypass",
42
+ "-Command",
43
+ script,
44
+ ], {
45
+ encoding: "utf8",
46
+ windowsHide: true,
47
+ timeout: 5000,
48
+ });
49
+ const stdout = String(result.stdout || "").trim();
50
+ if (stdout.includes("shown")) {
51
+ session.lastShownAt = new Date().toISOString();
52
+ return { shown: true, mode: "active_process", session_id, pid };
53
+ }
54
+ } catch (e) {
55
+ console.log(`[terminal] session ${session_id} active process show failed: ${e.message}`);
56
+ }
57
+ }
58
+
59
+ if (process.platform === "win32") {
60
+ try {
61
+ const title = `clauth terminal ${session.name || session_id}`;
62
+ const lines = [
63
+ `$Host.UI.RawUI.WindowTitle = ${JSON.stringify(title)}`,
64
+ `Set-Location -LiteralPath ${JSON.stringify(session.cwd || os.homedir())}`,
65
+ "Clear-Host",
66
+ `Write-Host ${JSON.stringify(`clauth terminal session ${session_id}`)} -ForegroundColor Cyan`,
67
+ `Write-Host ${JSON.stringify(`Name: ${session.name || ""}`)}`,
68
+ `Write-Host ${JSON.stringify(`Status: ${session.status}`)}`,
69
+ `Write-Host ${JSON.stringify(`CWD: ${session.cwd || ""}`)}`,
70
+ `Write-Host ${JSON.stringify(`Turn: ${session.turn || 0}`)}`,
71
+ `Write-Host ${JSON.stringify("This viewer is attached to clauth session metadata. If a Claude turn is active, terminal_show focuses it; otherwise this is the visible session inspection point.")} -ForegroundColor Yellow`,
72
+ "Write-Host ''",
73
+ "Write-Host 'Recent response preview:' -ForegroundColor Green",
74
+ `Write-Host ${JSON.stringify((session.lastResponse || "").slice(0, 1500))}`,
75
+ "Write-Host ''",
76
+ "Write-Host 'Leave this window open or close it when done.' -ForegroundColor DarkGray",
77
+ ].join("; ");
78
+ const child = spawnProc("powershell.exe", [
79
+ "-NoExit",
80
+ "-NoProfile",
81
+ "-ExecutionPolicy",
82
+ "Bypass",
83
+ "-Command",
84
+ lines,
85
+ ], {
86
+ cwd: session.cwd || os.homedir(),
87
+ env: process.env,
88
+ stdio: "ignore",
89
+ detached: true,
90
+ windowsHide: false,
91
+ });
92
+ child.unref?.();
93
+ session.lastShownAt = new Date().toISOString();
94
+ return { shown: true, mode: "session_inspector", session_id, pid: child.pid || null };
95
+ } catch (e) {
96
+ return { error: "show_failed", message: e.message };
97
+ }
98
+ }
99
+
100
+ return {
101
+ shown: false,
102
+ mode: "unsupported_platform",
103
+ session_id,
104
+ message: "terminal_show currently opens/focuses local Windows sessions only.",
105
+ };
106
+ }
@@ -0,0 +1,14 @@
1
+ // cli/services/vault-read.js
2
+ // One function: read a single credential value from the vault inside an MCP
3
+ // handler, honoring the caller's service whitelist. Split out of fs-mount.js
4
+ // for SRP — this is a vault-read concern, not a filesystem-mount concern.
5
+
6
+ import { deriveToken } from "../fingerprint.js";
7
+ import * as api from "../api.js";
8
+
9
+ export async function vaultRetrieveValue(vault, service) {
10
+ if (!vault.password) return { error: "locked" };
11
+ if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
12
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
13
+ return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
14
+ }
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { vaultRetrieveValue } from "./vault-read.js";
5
+
6
+ test("vaultRetrieveValue refuses when the vault is locked", async () => {
7
+ const result = await vaultRetrieveValue({ password: null }, "github");
8
+ assert.equal(result.error, "locked");
9
+ });
10
+
11
+ test("vaultRetrieveValue refuses a service outside the caller's whitelist", async () => {
12
+ const result = await vaultRetrieveValue({ password: "pw", machineHash: "h", whitelist: ["github"] }, "anthropic");
13
+ assert.equal(result.error, "not_in_whitelist");
14
+ });
15
+
16
+ test("vaultRetrieveValue's whitelist check is case-insensitive on the requested service", async () => {
17
+ // A case match ("GitHub" against ["github"]) passes the whitelist gate and
18
+ // falls through to a real vault round-trip -- mock fetch so this stays an
19
+ // offline unit test rather than a live network call.
20
+ const oldFetch = globalThis.fetch;
21
+ const oldUrl = process.env.CLAUTH_SUPABASE_URL;
22
+ const oldKey = process.env.CLAUTH_SUPABASE_ANON_KEY;
23
+ process.env.CLAUTH_SUPABASE_URL = "https://example.supabase.co";
24
+ process.env.CLAUTH_SUPABASE_ANON_KEY = "anon-key-not-real";
25
+ globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ value: "fake-value" }) });
26
+ try {
27
+ const result = await vaultRetrieveValue({ password: "pw", machineHash: "h", whitelist: ["github"] }, "GitHub");
28
+ assert.notEqual(result.error, "not_in_whitelist", "a case-insensitive whitelist match must not be rejected");
29
+ assert.equal(result.value, "fake-value");
30
+ } finally {
31
+ globalThis.fetch = oldFetch;
32
+ if (oldUrl === undefined) delete process.env.CLAUTH_SUPABASE_URL; else process.env.CLAUTH_SUPABASE_URL = oldUrl;
33
+ if (oldKey === undefined) delete process.env.CLAUTH_SUPABASE_ANON_KEY; else process.env.CLAUTH_SUPABASE_ANON_KEY = oldKey;
34
+ }
35
+ });
@@ -160,6 +160,122 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
160
160
  return { inspected };
161
161
  }
162
162
 
163
+ /**
164
+ * The URL that answers "is this surface up right now" for ANY surface, local
165
+ * or remote. `healthUrlForSurface` only ever resolves a LOCAL one: it needs an
166
+ * absolute health value or a port, and normalizeSurface() forbids a remote
167
+ * surface from carrying either (a loopback URL would probe this box, not the
168
+ * one the service actually runs on). So a remote surface's health path is
169
+ * joined onto the ORIGIN of its public route — `https://host/mcp` + `/health`
170
+ * is `https://host/health`, not `https://host/mcp/health`; the route names the
171
+ * endpoint, not a prefix.
172
+ */
173
+ function surfaceProbeUrl(surface) {
174
+ const local = healthUrlForSurface(surface);
175
+ if (local && /^https?:\/\//i.test(local)) return local;
176
+ const route = (surface?.routes || []).find((item) => item?.url && /^https?:\/\//i.test(item.url));
177
+ if (!route) return null;
178
+ const healthPath = String(surface.health || "/health");
179
+ if (/^https?:\/\//i.test(healthPath)) return healthPath;
180
+ try {
181
+ return new URL(healthPath.startsWith("/") ? healthPath : `/${healthPath}`, route.url).toString();
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ const MCP_TRANSPORT_PATHS = new Set(["/mcp", "/sse", "/message", "/messages", "/stream"]);
188
+
189
+ /**
190
+ * Where the dashboard's "Open" button should point: a browser-facing address,
191
+ * never a transport endpoint. Opening /mcp or /sse renders the raw SSE
192
+ * handshake, which is what clauth's own Open button used to do. Kept next to
193
+ * the health resolver on purpose — the two must agree about which box a
194
+ * surface lives on.
195
+ */
196
+ export function surfaceOpenUrl(surface) {
197
+ if (surface?.port && surface.port !== "auto") return `http://127.0.0.1:${surface.port}/`;
198
+ const route = (surface?.routes || []).find((item) => item?.kind === "external" && item?.url);
199
+ if (!route) return null;
200
+ try {
201
+ const parsed = new URL(route.url);
202
+ if (MCP_TRANSPORT_PATHS.has(parsed.pathname.replace(/\/+$/, "").toLowerCase())) {
203
+ parsed.pathname = "/";
204
+ parsed.search = "";
205
+ }
206
+ return parsed.toString();
207
+ } catch {
208
+ return route.url;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Read-only health sweep across EVERY surface. Probes and records; never
214
+ * starts, stops, restarts, or reconciles anything.
215
+ *
216
+ * This is deliberately NOT reconcileSurfaceHealth(). That one repairs what it
217
+ * finds broken, which is correct for a background loop the operator opted into
218
+ * and wrong for a UI refresh: focusing a browser tab must never restart a
219
+ * process. It also covers only enabled clauth-owned local pm2 surfaces, so the
220
+ * external and remote ones — most of the fleet — had no health signal at all.
221
+ *
222
+ * Health is written to its own `last_health_*` fields and never to `state`.
223
+ * They answer different questions: `state` is a MANIFEST fact (registered,
224
+ * enabled, hash unchanged — hence every surface reading `current` while one of
225
+ * them is not listening), health is a RUNTIME fact.
226
+ */
227
+ export async function probeAllSurfaceHealth({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
228
+ const probed = await Promise.all(listSurfaces().map(async (surface) => {
229
+ const surface_id = `${surface.plugin_id}:${surface.id}`;
230
+ const url = surfaceProbeUrl(surface);
231
+ // Probe the "Open" target as well as the health target. They are different
232
+ // questions and three surfaces in the live fleet answer them differently:
233
+ // an MCP server is healthy at /health and 404s at /, so an Open button
234
+ // pointed at its root opens an error page. Probing it here — inside a sweep
235
+ // that is already running — is what lets the button say so instead of
236
+ // finding out by clicking.
237
+ const openUrl = surfaceOpenUrl(surface);
238
+ const [health, open] = await Promise.all([
239
+ url ? probeSurfaceHealth(url, fetchImpl, timeoutMs) : null,
240
+ openUrl ? probeSurfaceHealth(openUrl, fetchImpl, timeoutMs) : null,
241
+ ]);
242
+ return {
243
+ surface_id,
244
+ health: !url ? "no_probe" : (health.healthy ? "healthy" : "down"),
245
+ url: url || null,
246
+ error: url && !health.healthy ? (health.error || null) : null,
247
+ open_url: openUrl || null,
248
+ open_ok: open ? open.healthy : null,
249
+ observed_at: now(),
250
+ };
251
+ }));
252
+
253
+ // ONE state write for the whole sweep. updateSurfaceState() is a
254
+ // load-modify-save per call, so N concurrent probes calling it would each
255
+ // save a copy loaded before its siblings finished — last writer wins and the
256
+ // rest are silently lost.
257
+ const byId = new Map(probed.map((entry) => [entry.surface_id, entry]));
258
+ const state = loadSupervisorState();
259
+ state.surfaces = (state.surfaces || []).map((surface) => {
260
+ const entry = byId.get(`${surface.plugin_id}:${surface.id}`);
261
+ if (!entry) return surface;
262
+ // last_health_url is the RESOLVED probe target, not the manifest's health
263
+ // field. A remote surface carries a relative "/health" by contract, so the
264
+ // manifest value alone cannot be opened in a browser — only the resolved
265
+ // one can, and it is the fallback when the Open root turns out to be dead.
266
+ const next = { ...surface, last_open_url: entry.open_url, last_open_ok: entry.open_ok, last_health_url: entry.url };
267
+ if (entry.health === "no_probe") return next;
268
+ return {
269
+ ...next,
270
+ last_health_at: entry.observed_at,
271
+ last_health_ok: entry.health === "healthy",
272
+ last_health_error: entry.health === "healthy" ? null : entry.error,
273
+ };
274
+ });
275
+ saveSupervisorState(state);
276
+ return { probed };
277
+ }
278
+
163
279
  function normalizeCommand(command, field) {
164
280
  if (!command) return [];
165
281
  if (!Array.isArray(command)) throw new Error(`${field} must be a command array`);
@@ -11,6 +11,8 @@ import {
11
11
  getClauthPm2Home,
12
12
  listPlugins,
13
13
  listSurfaces,
14
+ probeAllSurfaceHealth,
15
+ surfaceOpenUrl,
14
16
  reconcileSurfaceHealth,
15
17
  registerPlugin,
16
18
  runPluginAction,
@@ -461,6 +463,113 @@ test("health reconciliation never restarts external or plugin-owned surfaces", a
461
463
  }
462
464
  });
463
465
 
466
+ test("probeAllSurfaceHealth covers external and REMOTE surfaces, which reconcile skips entirely", async () => {
467
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-probe-all-"));
468
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
469
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
470
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
471
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
472
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
473
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
474
+ try {
475
+ writePlugin(root, "managed", "probe-demo", baseManifest("probe-demo", {
476
+ core: true,
477
+ enable_default: true,
478
+ surfaces: [
479
+ // clauth-owned local — the ONLY shape reconcileSurfaceHealth looks at.
480
+ { id: "local", destination: "local/clauth/pm2", lifecycle_owner: "clauth", port: 39211, health: "/health" },
481
+ // externally-owned local — reconcile skips on lifecycle_owner.
482
+ { id: "observed", destination: "local/clauth/pm2", lifecycle_owner: "external", port: 39212, health: "/health" },
483
+ // remote — reconcile skips on destination, AND it has no port and no
484
+ // absolute health by contract, so healthUrlForSurface() alone returns
485
+ // null for it. Its probe URL can only come from the route.
486
+ { id: "remote", destination: "vultr/clauth/pm2", lifecycle_owner: "external", health: "/health", routes: [{ id: "provider", kind: "external", url: "https://example.invalid/mcp", public: true }] },
487
+ ],
488
+ }));
489
+ discoverPlugins();
490
+ const seen = [];
491
+ const result = await probeAllSurfaceHealth({ fetchImpl: async (url) => { seen.push(url); return { ok: true, status: 200 }; } });
492
+ const ids = result.probed.map((p) => p.surface_id).sort();
493
+ assert.deepEqual(ids, ["probe-demo:local", "probe-demo:observed", "probe-demo:remote"]);
494
+ assert.ok(result.probed.every((p) => p.health === "healthy"), JSON.stringify(result.probed));
495
+ // The remote surface is probed at the route's ORIGIN plus the health path —
496
+ // https://host/health, NOT https://host/mcp/health. The route names the
497
+ // endpoint, not a path prefix, so joining onto it would 404 forever and
498
+ // report a live service as down.
499
+ assert.ok(seen.includes("https://example.invalid/health"),
500
+ `remote surface was not probed at its route origin (probed: ${JSON.stringify(seen)})`);
501
+ assert.ok(seen.includes("http://127.0.0.1:39212/health"));
502
+ // Every surface carries a fresh reading — the sweep is ONE state write, so
503
+ // a concurrent probe finishing first must not be clobbered by a later one.
504
+ for (const surface of listSurfaces()) {
505
+ assert.equal(surface.last_health_ok, true, `${surface.id} lost its health write`);
506
+ assert.ok(surface.last_health_at, `${surface.id} has no last_health_at`);
507
+ }
508
+ } finally {
509
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
510
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
511
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
512
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
513
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
514
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
515
+ fs.rmSync(root, { recursive: true, force: true });
516
+ }
517
+ });
518
+
519
+ test("probeAllSurfaceHealth is read-only — a failing probe never spawns a repair command", async () => {
520
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-probe-readonly-"));
521
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
522
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
523
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
524
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
525
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
526
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
527
+ const marker = path.join(root, "restart-ran.txt").replaceAll("\\", "/");
528
+ try {
529
+ // The restart command writes a marker file. If the probe ever repairs, the
530
+ // file exists — that is the whole test, and it cannot pass by accident.
531
+ writePlugin(root, "managed", "readonly-demo", baseManifest("readonly-demo", {
532
+ core: true,
533
+ enable_default: true,
534
+ surfaces: [{
535
+ id: "primary",
536
+ destination: "local/clauth/pm2",
537
+ lifecycle_owner: "clauth",
538
+ port: 39213,
539
+ health: "/health",
540
+ // Path passed as an ARG, not baked into the -e script: on Windows these
541
+ // commands run under shell:true, so an embedded double-quoted path is
542
+ // re-quoted by cmd.exe and the write silently lands nowhere.
543
+ restart: [process.execPath, "-e", "require('fs').writeFileSync(process.argv[1],'ran')", marker],
544
+ }],
545
+ }));
546
+ discoverPlugins();
547
+ const result = await probeAllSurfaceHealth({ fetchImpl: async () => ({ ok: false, status: 503 }) });
548
+ assert.equal(result.probed[0].health, "down");
549
+ assert.equal(fs.existsSync(marker), false, "probeAllSurfaceHealth spawned the restart command — it is not read-only");
550
+ // Health is recorded, but `state` — a MANIFEST fact — is left alone.
551
+ // reconcileSurfaceHealth rewrites it to "unavailable"; a read-only sweep
552
+ // must not, or looking at the dashboard would rewrite registry state.
553
+ const surface = listSurfaces()[0];
554
+ assert.equal(surface.last_health_ok, false);
555
+ assert.equal(surface.state, "current");
556
+
557
+ // Positive control: the marker mechanism WORKS. Without this, "the file was
558
+ // absent" is equally consistent with a command that could never write it.
559
+ runSurfaceAction("readonly-demo:primary", "restart", "test");
560
+ assert.equal(fs.existsSync(marker), true,
561
+ "control failed — the restart command cannot write the marker, so its absence above proved nothing");
562
+ } finally {
563
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
564
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
565
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
566
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
567
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
568
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
569
+ fs.rmSync(root, { recursive: true, force: true });
570
+ }
571
+ });
572
+
464
573
  test("supervisor write-token policy is temporarily relaxed only for the localhost supervisor port", () => {
465
574
  assert.equal(supervisorRequiresWriteToken(52439, {}), false);
466
575
  assert.equal(supervisorRequiresWriteToken(52439, { CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN: "1" }), true);
@@ -962,3 +1071,59 @@ test("registerPlugin resolves ${PACKAGE_ROOT} to the manifest's real origin", ()
962
1071
  assert.ok(!JSON.stringify(surface).includes("PACKAGE_ROOT"),
963
1072
  "an unexpanded PACKAGE_ROOT token survived into registered state");
964
1073
  }));
1074
+
1075
+ test("the health sweep also probes the Open target, so a 404 root is known before it is clicked", async () => {
1076
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-open-probe-"));
1077
+ const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
1078
+ const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
1079
+ const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
1080
+ process.env.CLAUTH_SUPERVISOR_DIR = root;
1081
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
1082
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
1083
+ try {
1084
+ writePlugin(root, "managed", "open-demo", baseManifest("open-demo", {
1085
+ core: true,
1086
+ enable_default: true,
1087
+ surfaces: [{ id: "primary", destination: "local/clauth/pm2", lifecycle_owner: "clauth", port: 39311, health: "/health" }],
1088
+ }));
1089
+ discoverPlugins();
1090
+ // The real shape of an MCP server: healthy at /health, 404 at /. Before
1091
+ // this, the Open button pointed at / and opened an error page.
1092
+ const result = await probeAllSurfaceHealth({
1093
+ fetchImpl: async (url) => ({ ok: String(url).endsWith("/health"), status: String(url).endsWith("/health") ? 200 : 404 }),
1094
+ });
1095
+ const entry = result.probed[0];
1096
+ assert.equal(entry.health, "healthy", "control: the health endpoint must read healthy, or open_ok proves nothing");
1097
+ assert.equal(entry.open_url, "http://127.0.0.1:39311/");
1098
+ assert.equal(entry.open_ok, false, "a 404 root must be recorded as a bad Open target");
1099
+ assert.equal(listSurfaces()[0].last_health_url, "http://127.0.0.1:39311/health", "the RESOLVED probe url must be stored — it is what the Open button falls back to");
1100
+ assert.equal(listSurfaces()[0].last_open_ok, false);
1101
+
1102
+ // Inverse: a surface whose root DOES answer is not flagged.
1103
+ const good = await probeAllSurfaceHealth({ fetchImpl: async () => ({ ok: true, status: 200 }) });
1104
+ assert.equal(good.probed[0].open_ok, true);
1105
+ assert.equal(listSurfaces()[0].last_open_ok, true);
1106
+ } finally {
1107
+ if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
1108
+ else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
1109
+ if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
1110
+ else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
1111
+ if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
1112
+ else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
1113
+ fs.rmSync(root, { recursive: true, force: true });
1114
+ }
1115
+ });
1116
+
1117
+ test("surfaceOpenUrl never points a browser at an MCP transport endpoint", () => {
1118
+ assert.equal(surfaceOpenUrl({ port: 3110 }), "http://127.0.0.1:3110/");
1119
+ assert.equal(
1120
+ surfaceOpenUrl({ routes: [{ kind: "external", url: "https://media.regendevcorp.com/mcp" }] }),
1121
+ "https://media.regendevcorp.com/",
1122
+ );
1123
+ // A real application path is not a transport path and survives untouched.
1124
+ assert.equal(
1125
+ surfaceOpenUrl({ routes: [{ kind: "external", url: "https://fs.regendevcorp.com/fs" }] }),
1126
+ "https://fs.regendevcorp.com/fs",
1127
+ );
1128
+ assert.equal(surfaceOpenUrl({}), null);
1129
+ });