@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.
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +413 -396
- package/cli/commands/login.test.js +49 -1
- package/cli/commands/ops-install.js +11 -10
- package/cli/commands/ops.test.js +270 -0
- package/cli/commands/scrub.test.js +70 -0
- package/cli/commands/serve.js +2613 -11219
- package/cli/commands/uninstall.js +164 -164
- package/cli/dashboard/dashboard.js +592 -0
- package/cli/dashboard/global.css +65 -0
- package/cli/dashboard/panel-element.js +79 -0
- package/cli/dashboard/panels/build-status-panel.css +12 -0
- package/cli/dashboard/panels/build-status-panel.js +98 -0
- package/cli/dashboard/panels/panel-registry.js +58 -0
- package/cli/dashboard/panels/services-panel.css +95 -0
- package/cli/dashboard/panels/services-panel.js +872 -0
- package/cli/dashboard/panels/surfaces-panel.css +59 -0
- package/cli/dashboard/panels/surfaces-panel.js +474 -0
- package/cli/dashboard/panels/toolbar-panel.css +42 -0
- package/cli/dashboard/panels/toolbar-panel.js +374 -0
- package/cli/dashboard/panels/tunnel-panel.css +61 -0
- package/cli/dashboard/panels/tunnel-panel.js +688 -0
- package/cli/dashboard/panels/webdav-panel.css +38 -0
- package/cli/dashboard/panels/webdav-panel.js +166 -0
- package/cli/fingerprint.test.js +95 -0
- package/cli/http/components/callagent-terminal-component.js +310 -0
- package/cli/http/components/dashboard-component.js +123 -0
- package/cli/http/components/inbox-component.js +41 -0
- package/cli/http/components/mcp-transport-component.js +275 -0
- package/cli/http/components/oauth-component.js +207 -0
- package/cli/http/components/service-crud-component.js +288 -0
- package/cli/http/components/supervisor-component.js +104 -0
- package/cli/http/components/system-component.js +184 -0
- package/cli/http/components/tunnel-component.js +508 -0
- package/cli/http/components/vault-auth-component.js +264 -0
- package/cli/http/components/watchdog-component.js +28 -0
- package/cli/http/request-utils.js +21 -0
- package/cli/http/servers/ops-server.js +335 -0
- package/cli/index.js +5 -91
- package/cli/lib/fs-git.test.js +467 -0
- package/cli/mcp/providers/call-agent-provider.js +30 -0
- package/cli/mcp/providers/clauth-core-provider.js +461 -0
- package/cli/mcp/providers/ops-provider.js +42 -0
- package/cli/mcp/providers/terminal-provider.js +81 -0
- package/cli/mcp/tool-registry.js +48 -0
- package/cli/mcp/tool-schemas.js +822 -0
- package/cli/mcp/tool-schemas.test.js +49 -0
- package/cli/ops/pm2-preflight.js +54 -0
- package/cli/recovery.js +6 -0
- package/cli/recovery.test.js +179 -0
- package/cli/services/agent-pool-instance.js +38 -0
- package/cli/services/claude-binary.js +29 -0
- package/cli/services/delegation-lane-instance.js +35 -0
- package/cli/services/delegation-lane-instance.test.js +37 -0
- package/cli/services/file-io.js +38 -0
- package/cli/services/file-io.test.js +10 -0
- package/cli/services/fs-mount.js +66 -0
- package/cli/services/fs-mount.test.js +14 -0
- package/cli/services/fs-upload-sessions.js +20 -0
- package/cli/services/fs-upload-sessions.test.js +14 -0
- package/cli/services/git-exec.js +37 -0
- package/cli/services/git-exec.test.js +41 -0
- package/cli/services/git-import-guard.js +28 -0
- package/cli/services/git-import-guard.test.js +18 -0
- package/cli/services/service-status.js +191 -0
- package/cli/services/service-status.test.js +39 -0
- package/cli/services/terminal-dispatch.js +135 -0
- package/cli/services/terminal-dispatch.test.js +9 -0
- package/cli/services/terminal-registry.js +39 -0
- package/cli/services/terminal-registry.test.js +31 -0
- package/cli/services/terminal-sessions.js +100 -0
- package/cli/services/terminal-sessions.test.js +26 -0
- package/cli/services/terminal-window.js +106 -0
- package/cli/services/vault-read.js +14 -0
- package/cli/services/vault-read.test.js +35 -0
- package/cli/supervisor-registry.js +116 -0
- package/cli/supervisor-registry.test.js +165 -0
- package/cli/supervisor-ui.test.js +230 -30
- package/cli/webdav-config.js +84 -0
- package/cli/webdav-config.test.js +79 -0
- package/cli/webdav-obscure.js +23 -0
- package/cli/webdav-obscure.test.js +41 -0
- package/cli/webdav-rclone-discovery.js +44 -0
- package/cli/webdav-service.js +30 -118
- package/cli/webdav-service.test.js +107 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
- 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
|
+
});
|