@lifeaitools/clauth 1.30.22 → 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 +43 -86
  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,115 +1,115 @@
1
- // node --test cli/commands/scrub.test.js
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import test from "node:test";
7
-
8
- import {
9
- PATTERNS,
10
- findTranscripts,
11
- scrubFile,
12
- isSecretLike,
13
- loadExtraPatterns,
14
- sessionTargets,
15
- } from "./scrub.js";
16
-
17
- const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
18
- const VAULT_VALUE = "SuperSecretVaultValue_abc123XYZ"; // secret-like literal
19
- const CUSTOM_SECRET = "MYCORP-TOKEN-998877"; // only an editable pattern catches it
20
-
21
- function seedProjects() {
22
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-scrub-"));
23
- const sess = path.join(root, "proj", "session-uuid");
24
- const sidecarDir = path.join(sess, "tool-results");
25
- fs.mkdirSync(sidecarDir, { recursive: true });
26
-
27
- const jsonl = path.join(root, "proj", "session-uuid.jsonl");
28
- const txt = path.join(sidecarDir, "toolu_abc.txt");
29
- const body = `token=${GH_TOKEN} value=${VAULT_VALUE} custom=${CUSTOM_SECRET}\n`;
30
- fs.writeFileSync(jsonl, `{"x":"${body.trim()}"}\n`, "utf-8");
31
- fs.writeFileSync(txt, body, "utf-8"); // the sidecar that the old scrubber skipped
32
- return { root, jsonl, txt };
33
- }
34
-
35
- test("findTranscripts discovers .jsonl AND tool-results/*.txt sidecars", () => {
36
- const { root, jsonl, txt } = seedProjects();
37
- const found = findTranscripts(root);
38
- assert.ok(found.includes(jsonl), "should find the .jsonl transcript");
39
- assert.ok(found.includes(txt), "should find the .txt sidecar (the previously-skipped leak path)");
40
- });
41
-
42
- test("scrubFile redacts the github token in BOTH the jsonl and the sidecar", () => {
43
- const { jsonl, txt } = seedProjects();
44
- for (const f of [jsonl, txt]) {
45
- const n = scrubFile(f, { force: true, patterns: PATTERNS, literals: [] });
46
- assert.ok(n >= 1, `expected >=1 redaction in ${path.basename(f)}`);
47
- const after = fs.readFileSync(f, "utf-8");
48
- assert.ok(!after.includes(GH_TOKEN), "github token must be gone");
49
- assert.ok(after.includes("[GITHUB_TOKEN_REDACTED]"), "redaction marker present");
50
- assert.ok(after.includes("[CLAUTH-SCRUBBED]"), "file stamped as scrubbed");
51
- }
52
- });
53
-
54
- test("vault-value (literal) redaction removes an arbitrary secret regardless of format", () => {
55
- const { txt } = seedProjects();
56
- const n = scrubFile(txt, { force: true, patterns: [], literals: [{ name: "test-svc", value: VAULT_VALUE }] });
57
- assert.equal(n, 1, "exactly one literal occurrence redacted");
58
- const after = fs.readFileSync(txt, "utf-8");
59
- assert.ok(!after.includes(VAULT_VALUE), "vault value must be gone");
60
- assert.ok(after.includes("[CLAUTH:test-svc_REDACTED]"), "labelled vault redaction present");
61
- });
62
-
63
- test("editable patterns file catches a custom secret with no clauth release", () => {
64
- const cfg = path.join(os.tmpdir(), `clauth-scrub-patterns-${Date.now()}.json`);
65
- fs.writeFileSync(cfg, JSON.stringify([{ pattern: "MYCORP-TOKEN-\\d+", replacement: "[MYCORP_REDACTED]" }]), "utf-8");
66
- const extra = loadExtraPatterns(cfg);
67
- assert.equal(extra.length, 1, "one extra pattern loaded");
68
-
69
- const { txt } = seedProjects();
70
- const n = scrubFile(txt, { force: true, patterns: extra, literals: [] });
71
- assert.ok(n >= 1, "custom pattern matched");
72
- const after = fs.readFileSync(txt, "utf-8");
73
- assert.ok(!after.includes(CUSTOM_SECRET), "custom secret gone");
74
- assert.ok(after.includes("[MYCORP_REDACTED]"), "custom replacement applied");
75
- fs.rmSync(cfg, { force: true });
76
- });
77
-
78
- test("isSecretLike: accepts real tokens, rejects short/url/whitespace values", () => {
79
- assert.ok(isSecretLike(GH_TOKEN), "long token is secret-like");
80
- assert.ok(isSecretLike(VAULT_VALUE), "31-char no-space value is secret-like");
81
- assert.ok(!isSecretLike("short"), "short value rejected");
82
- assert.ok(!isSecretLike("https://research.regendevcorp.com/mcp"), "plain URL rejected");
83
- assert.ok(!isSecretLike("has spaces in it value"), "whitespace value rejected");
84
- assert.ok(!isSecretLike(""), "empty rejected");
85
- });
86
-
87
- test("sessionTargets returns ONLY the ending session's transcript + its sidecars", () => {
88
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sess-"));
89
- const proj = path.join(root, "C--proj");
90
- const sid = "11112222-3333-4444-5555-666677778888";
91
- fs.mkdirSync(path.join(proj, sid, "tool-results"), { recursive: true });
92
- const transcript = path.join(proj, `${sid}.jsonl`);
93
- const sidecar = path.join(proj, sid, "tool-results", "toolu_x.txt");
94
- const otherSession = path.join(proj, "99990000-aaaa-bbbb-cccc-ddddeeeeffff.jsonl");
95
- fs.writeFileSync(transcript, "{}\n");
96
- fs.writeFileSync(sidecar, "tool output\n");
97
- fs.writeFileSync(otherSession, "{}\n"); // a DIFFERENT session — must NOT be included
98
-
99
- const targets = sessionTargets({ transcript_path: transcript, session_id: sid });
100
- assert.ok(targets.includes(transcript), "includes the session transcript");
101
- assert.ok(targets.includes(sidecar), "includes the session's sidecar");
102
- assert.ok(!targets.includes(otherSession), "does NOT include other sessions (session-only)");
103
- assert.equal(targets.length, 2, "exactly the 2 session files");
104
-
105
- assert.deepEqual(sessionTargets(null), [], "no hook input → no targets (caller falls back)");
106
- assert.deepEqual(sessionTargets({ transcript_path: path.join(root, "nope.jsonl") }), [], "missing file → none");
107
- });
108
-
109
- test("loadExtraPatterns tolerates a missing/malformed file", () => {
110
- assert.deepEqual(loadExtraPatterns(path.join(os.tmpdir(), "does-not-exist-xyz.json")), []);
111
- const bad = path.join(os.tmpdir(), `clauth-bad-${Date.now()}.json`);
112
- fs.writeFileSync(bad, "{ not json", "utf-8");
113
- assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
114
- fs.rmSync(bad, { force: true });
115
- });
1
+ // node --test cli/commands/scrub.test.js
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ import {
9
+ PATTERNS,
10
+ findTranscripts,
11
+ scrubFile,
12
+ isSecretLike,
13
+ loadExtraPatterns,
14
+ sessionTargets,
15
+ } from "./scrub.js";
16
+
17
+ const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
18
+ const VAULT_VALUE = "SuperSecretVaultValue_abc123XYZ"; // secret-like literal
19
+ const CUSTOM_SECRET = "MYCORP-TOKEN-998877"; // only an editable pattern catches it
20
+
21
+ function seedProjects() {
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-scrub-"));
23
+ const sess = path.join(root, "proj", "session-uuid");
24
+ const sidecarDir = path.join(sess, "tool-results");
25
+ fs.mkdirSync(sidecarDir, { recursive: true });
26
+
27
+ const jsonl = path.join(root, "proj", "session-uuid.jsonl");
28
+ const txt = path.join(sidecarDir, "toolu_abc.txt");
29
+ const body = `token=${GH_TOKEN} value=${VAULT_VALUE} custom=${CUSTOM_SECRET}\n`;
30
+ fs.writeFileSync(jsonl, `{"x":"${body.trim()}"}\n`, "utf-8");
31
+ fs.writeFileSync(txt, body, "utf-8"); // the sidecar that the old scrubber skipped
32
+ return { root, jsonl, txt };
33
+ }
34
+
35
+ test("findTranscripts discovers .jsonl AND tool-results/*.txt sidecars", () => {
36
+ const { root, jsonl, txt } = seedProjects();
37
+ const found = findTranscripts(root);
38
+ assert.ok(found.includes(jsonl), "should find the .jsonl transcript");
39
+ assert.ok(found.includes(txt), "should find the .txt sidecar (the previously-skipped leak path)");
40
+ });
41
+
42
+ test("scrubFile redacts the github token in BOTH the jsonl and the sidecar", () => {
43
+ const { jsonl, txt } = seedProjects();
44
+ for (const f of [jsonl, txt]) {
45
+ const n = scrubFile(f, { force: true, patterns: PATTERNS, literals: [] });
46
+ assert.ok(n >= 1, `expected >=1 redaction in ${path.basename(f)}`);
47
+ const after = fs.readFileSync(f, "utf-8");
48
+ assert.ok(!after.includes(GH_TOKEN), "github token must be gone");
49
+ assert.ok(after.includes("[GITHUB_TOKEN_REDACTED]"), "redaction marker present");
50
+ assert.ok(after.includes("[CLAUTH-SCRUBBED]"), "file stamped as scrubbed");
51
+ }
52
+ });
53
+
54
+ test("vault-value (literal) redaction removes an arbitrary secret regardless of format", () => {
55
+ const { txt } = seedProjects();
56
+ const n = scrubFile(txt, { force: true, patterns: [], literals: [{ name: "test-svc", value: VAULT_VALUE }] });
57
+ assert.equal(n, 1, "exactly one literal occurrence redacted");
58
+ const after = fs.readFileSync(txt, "utf-8");
59
+ assert.ok(!after.includes(VAULT_VALUE), "vault value must be gone");
60
+ assert.ok(after.includes("[CLAUTH:test-svc_REDACTED]"), "labelled vault redaction present");
61
+ });
62
+
63
+ test("editable patterns file catches a custom secret with no clauth release", () => {
64
+ const cfg = path.join(os.tmpdir(), `clauth-scrub-patterns-${Date.now()}.json`);
65
+ fs.writeFileSync(cfg, JSON.stringify([{ pattern: "MYCORP-TOKEN-\\d+", replacement: "[MYCORP_REDACTED]" }]), "utf-8");
66
+ const extra = loadExtraPatterns(cfg);
67
+ assert.equal(extra.length, 1, "one extra pattern loaded");
68
+
69
+ const { txt } = seedProjects();
70
+ const n = scrubFile(txt, { force: true, patterns: extra, literals: [] });
71
+ assert.ok(n >= 1, "custom pattern matched");
72
+ const after = fs.readFileSync(txt, "utf-8");
73
+ assert.ok(!after.includes(CUSTOM_SECRET), "custom secret gone");
74
+ assert.ok(after.includes("[MYCORP_REDACTED]"), "custom replacement applied");
75
+ fs.rmSync(cfg, { force: true });
76
+ });
77
+
78
+ test("isSecretLike: accepts real tokens, rejects short/url/whitespace values", () => {
79
+ assert.ok(isSecretLike(GH_TOKEN), "long token is secret-like");
80
+ assert.ok(isSecretLike(VAULT_VALUE), "31-char no-space value is secret-like");
81
+ assert.ok(!isSecretLike("short"), "short value rejected");
82
+ assert.ok(!isSecretLike("https://research.regendevcorp.com/mcp"), "plain URL rejected");
83
+ assert.ok(!isSecretLike("has spaces in it value"), "whitespace value rejected");
84
+ assert.ok(!isSecretLike(""), "empty rejected");
85
+ });
86
+
87
+ test("sessionTargets returns ONLY the ending session's transcript + its sidecars", () => {
88
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sess-"));
89
+ const proj = path.join(root, "C--proj");
90
+ const sid = "11112222-3333-4444-5555-666677778888";
91
+ fs.mkdirSync(path.join(proj, sid, "tool-results"), { recursive: true });
92
+ const transcript = path.join(proj, `${sid}.jsonl`);
93
+ const sidecar = path.join(proj, sid, "tool-results", "toolu_x.txt");
94
+ const otherSession = path.join(proj, "99990000-aaaa-bbbb-cccc-ddddeeeeffff.jsonl");
95
+ fs.writeFileSync(transcript, "{}\n");
96
+ fs.writeFileSync(sidecar, "tool output\n");
97
+ fs.writeFileSync(otherSession, "{}\n"); // a DIFFERENT session — must NOT be included
98
+
99
+ const targets = sessionTargets({ transcript_path: transcript, session_id: sid });
100
+ assert.ok(targets.includes(transcript), "includes the session transcript");
101
+ assert.ok(targets.includes(sidecar), "includes the session's sidecar");
102
+ assert.ok(!targets.includes(otherSession), "does NOT include other sessions (session-only)");
103
+ assert.equal(targets.length, 2, "exactly the 2 session files");
104
+
105
+ assert.deepEqual(sessionTargets(null), [], "no hook input → no targets (caller falls back)");
106
+ assert.deepEqual(sessionTargets({ transcript_path: path.join(root, "nope.jsonl") }), [], "missing file → none");
107
+ });
108
+
109
+ test("loadExtraPatterns tolerates a missing/malformed file", () => {
110
+ assert.deepEqual(loadExtraPatterns(path.join(os.tmpdir(), "does-not-exist-xyz.json")), []);
111
+ const bad = path.join(os.tmpdir(), `clauth-bad-${Date.now()}.json`);
112
+ fs.writeFileSync(bad, "{ not json", "utf-8");
113
+ assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
114
+ fs.rmSync(bad, { force: true });
115
+ });
@@ -486,7 +486,7 @@ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
486
486
  const LIVE_PORT = 52437;
487
487
  const STAGED_PORT = 52438;
488
488
  const WRITE_TOKEN_BYTES = 32;
489
- const WRITE_TOKEN_TTL_MS = 30 * 60 * 1000;
489
+ const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
490
490
 
491
491
  function makeWriteToken() {
492
492
  return {
@@ -677,9 +677,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
677
677
  .btn-unlock:hover{background:#2563eb}
678
678
  .btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
679
679
  .lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
680
- #write-unlock-overlay{display:none;position:fixed;inset:0;background:rgba(2,6,23,.72);z-index:9999;align-items:center;justify-content:center;padding:2rem}
681
- .write-unlock-cancel{width:100%;background:transparent;color:#94a3b8;border:1px solid #334155;border-radius:8px;padding:8px;font-size:.85rem;cursor:pointer;margin-top:.6rem;transition:border-color .15s}
682
- .write-unlock-cancel:hover{border-color:#64748b;color:#cbd5e1}
683
680
  /* ── Main view ── */
684
681
  #main-view{display:none;padding:2rem}
685
682
  .header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
@@ -933,7 +930,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
933
930
  .supervisor-name{font-size:.82rem;color:#e2e8f0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
934
931
  .supervisor-row-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}
935
932
  .supervisor-log{font-family:'Courier New',monospace;font-size:.7rem;color:#94a3b8;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow:auto}
936
- .supervisor-feedback{margin-top:8px;padding:9px 12px;border-radius:6px;font-family:'Courier New',monospace;font-size:.88rem;font-weight:600;color:#86efac;background:rgba(34,197,94,.12);border:1px solid rgba(74,222,128,.35);border-left:4px solid #4ade80;display:none}
933
+ .supervisor-feedback{margin-top:8px;padding:7px 9px;border-radius:6px;font-family:'Courier New',monospace;font-size:.72rem;color:#86efac;background:rgba(34,197,94,.08);border:1px solid rgba(74,222,128,.22);display:none}
937
934
  .supervisor-feedback.bad{color:#fecaca;background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25)}
938
935
  </style>
939
936
  </head>
@@ -954,24 +951,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
954
951
  </div>
955
952
  </div>
956
953
 
957
- <!-- Write-unlock modal (replaces window.prompt(), which silently no-ops in
958
- embedded/webview browser contexts and after a browser suppresses repeated
959
- native dialogs) -->
960
- <div id="write-unlock-overlay">
961
- <div class="lock-card">
962
- <div class="lock-icon">🔒</div>
963
- <div class="lock-title">Enable writes</div>
964
- <div class="lock-sub">Enter your vault password to enable saving changes (30-minute write session)</div>
965
- <form onsubmit="submitWriteUnlock();return false;" autocomplete="on">
966
- <input type="text" name="username" value="clauth" autocomplete="username" style="display:none">
967
- <input class="lock-input" id="write-unlock-input" type="password" placeholder="••••••••••••" autocomplete="current-password">
968
- <button class="btn-unlock" id="write-unlock-btn" type="submit">Unlock Writes</button>
969
- </form>
970
- <div class="lock-err" id="write-unlock-err"></div>
971
- <button type="button" class="write-unlock-cancel" onclick="closeWriteUnlockModal()">Cancel</button>
972
- </div>
973
- </div>
974
-
975
954
  <!-- ── Main view (shown after unlock) ──────── -->
976
955
  <div id="main-view">
977
956
  <div id="upgrade-banner" style="display:none" class="upgrade-banner">
@@ -1187,6 +1166,12 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
1187
1166
  <div id="supervisor-status" class="supervisor-sub">Loading supervisor state…</div>
1188
1167
  <div id="supervisor-feedback" class="supervisor-feedback" role="status"></div>
1189
1168
  <div class="supervisor-grid" style="margin-top:10px">
1169
+ <div class="supervisor-card">
1170
+ <h4>Plugins</h4>
1171
+ <div class="supervisor-kpi" id="supervisor-plugin-count">—</div>
1172
+ <div class="supervisor-meta" id="supervisor-plugin-meta">awaiting data</div>
1173
+ <div class="supervisor-list" id="supervisor-plugins" style="margin-top:8px"></div>
1174
+ </div>
1190
1175
  <div class="supervisor-card">
1191
1176
  <h4>Surfaces</h4>
1192
1177
  <div class="supervisor-kpi" id="supervisor-surface-count">—</div>
@@ -1509,16 +1494,21 @@ async function loadSupervisorCockpit() {
1509
1494
  const status = document.getElementById("supervisor-status");
1510
1495
  if (status) status.textContent = "Loading supervisor state…";
1511
1496
  try {
1512
- const [health, surfaces, logs] = await Promise.all([
1497
+ const [health, plugins, surfaces, logs] = await Promise.all([
1513
1498
  supervisorJson("/health"),
1499
+ supervisorJson("/v1/plugins"),
1514
1500
  supervisorJson("/v1/surfaces"),
1515
1501
  supervisorJson("/v1/logs?limit=40"),
1516
1502
  ]);
1503
+ const pluginRows = plugins.plugins || [];
1517
1504
  const surfaceRows = surfaces.surfaces || [];
1505
+ document.getElementById("supervisor-plugin-count").textContent = String(pluginRows.length);
1518
1506
  document.getElementById("supervisor-surface-count").textContent = String(surfaceRows.length);
1519
1507
  document.getElementById("supervisor-operation-count").textContent = String((logs.operations || []).length);
1508
+ document.getElementById("supervisor-plugin-meta").textContent = "current " + (pluginRows.filter(p => p.state === "current").length) + " · awaiting " + (pluginRows.filter(p => p.state === "awaiting_enable").length);
1520
1509
  document.getElementById("supervisor-surface-meta").textContent = "pm2 home: " + (health.pm2_home || "—");
1521
1510
  document.getElementById("supervisor-log-path").textContent = logs.log_path || "events.jsonl";
1511
+ document.getElementById("supervisor-plugins").innerHTML = pluginRows.length ? pluginRows.map(renderSupervisorPlugin).join("") : '<div class="supervisor-sub">No plugins discovered.</div>';
1522
1512
  document.getElementById("supervisor-surfaces").innerHTML = surfaceRows.length ? surfaceRows.map(renderSupervisorSurface).join("") : '<div class="supervisor-sub">No surfaces declared.</div>';
1523
1513
  document.getElementById("supervisor-events").textContent = (logs.events || []).slice(-20).reverse().map(e => {
1524
1514
  const label = e.kind === "operation" ? (e.action + " " + JSON.stringify(e.target || {})) : (e.kind + " " + (e.plugin_id || ""));
@@ -1531,13 +1521,21 @@ async function loadSupervisorCockpit() {
1531
1521
  }
1532
1522
  }
1533
1523
 
1524
+ function renderSupervisorPlugin(plugin) {
1525
+ const stateKind = plugin.state === "current" ? "ok" : (plugin.state === "manifest_invalid" || plugin.state === "missing" ? "bad" : "warn");
1526
+ return '<div class="supervisor-row"><div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(plugin.id) + '</span>' + supervisorBadge(plugin.state || "unknown", stateKind) + '</div>' +
1527
+ '<div class="supervisor-meta">' + htmlEscape(plugin.publisher || "unknown") + ' · ' + htmlEscape(plugin.version || "—") + ' · ' + htmlEscape(plugin.source || "—") + '</div>' +
1528
+ '<div class="supervisor-meta">' + htmlEscape((plugin.manifest_hash || "").slice(0, 12)) + '</div>' +
1529
+ '<div class="supervisor-row-actions"><button class="supervisor-action" onclick="runSupervisorPlugin(' + jsArg(plugin.id) + ',' + jsArg(plugin.enabled ? "disable" : "enable") + ')">' + (plugin.enabled ? "Disable" : "Enable") + '</button><button class="supervisor-action" onclick="runSupervisorPlugin(' + jsArg(plugin.id) + ',' + jsArg("test") + ')">Test</button><button class="supervisor-action" onclick="runSupervisorPlugin(' + jsArg(plugin.id) + ',' + jsArg("promote") + ')">Promote</button></div></div>';
1530
+ }
1531
+
1534
1532
  function renderSupervisorSurface(surface) {
1535
1533
  const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
1536
1534
  const stateKind = surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn");
1537
1535
  return '<div class="supervisor-row"><div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.plugin_id + ":" + surface.id) + '</span>' + supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(surface.state || surface.status || "unknown", stateKind) + '</div>' +
1538
1536
  '<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
1539
1537
  '<div class="supervisor-meta">' + htmlEscape(surface.health || surface.health_url || "no health") + '</div>' +
1540
- '<div class="supervisor-row-actions">' + ["start","stop","restart","reconcile","test","promote","rollback"].map(a => '<button class="supervisor-action" data-supervisor-action="' + a + '" onclick="runSupervisorSurface(' + jsArg(surface.plugin_id + ":" + surface.id) + ',' + jsArg(a) + ',this)">' + a + '</button>').join("") + '</div></div>';
1538
+ '<div class="supervisor-row-actions">' + ["start","stop","restart","reconcile","test","promote","rollback"].map(a => '<button class="supervisor-action" data-supervisor-action="' + a + '" onclick="runSupervisorSurface(' + jsArg(surface.id) + ',' + jsArg(a) + ')">' + a + '</button>').join("") + '</div></div>';
1541
1539
  }
1542
1540
 
1543
1541
  async function rescanSupervisorPlugins() {
@@ -1548,23 +1546,21 @@ async function rescanSupervisorPlugins() {
1548
1546
  } catch (err) { supervisorFeedback("Plugin rescan failed: " + (err.message || err), true); }
1549
1547
  }
1550
1548
 
1551
- async function runSupervisorSurface(id, action, btn) {
1552
- const originalLabel = btn ? btn.textContent : null;
1553
- if (btn) { btn.disabled = true; btn.textContent = "..."; btn.style.opacity = "0.6"; }
1554
- supervisorFeedback(action + " " + id + ": sending...", false);
1549
+ async function runSupervisorPlugin(id, action) {
1550
+ try {
1551
+ const receipt = await supervisorJson("/v1/plugins/" + encodeURIComponent(id) + "/" + encodeURIComponent(action), { method: "POST", headers: writeHeaders() });
1552
+ supervisorFeedback("Plugin " + action + " receipt " + (receipt.operationId || "recorded") + " · " + ((receipt.resulting_state && receipt.resulting_state.state) || "completed"), false);
1553
+ await loadSupervisorCockpit();
1554
+ } catch (err) { supervisorFeedback("Plugin " + action + " failed: " + (err.message || err), true); }
1555
+ }
1556
+
1557
+ async function runSupervisorSurface(id, action) {
1555
1558
  try {
1556
1559
  const receipt = await supervisorJson("/v1/surfaces/" + encodeURIComponent(id) + "/actions", { method: "POST", headers: writeHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ action }) });
1557
1560
  const result = receipt.resulting_state || {};
1558
- const ok = result.ok !== false;
1559
- if (btn) { btn.textContent = ok ? "done" : "failed"; btn.style.background = ok ? "#14532d" : "#7f1d1d"; }
1560
- supervisorFeedback(id + " " + action + " " + (ok ? "succeeded" : "failed") + " " + " -> " + " " + (result.state || result.reason || "completed"), !ok);
1561
- await new Promise((resolve) => setTimeout(resolve, 700));
1561
+ supervisorFeedback("Surface " + action + " receipt " + (receipt.operationId || "recorded") + " · " + (result.state || "completed"), result.ok === false);
1562
1562
  await loadSupervisorCockpit();
1563
- } catch (err) {
1564
- if (btn) { btn.textContent = "failed"; btn.style.background = "#7f1d1d"; }
1565
- supervisorFeedback(id + " " + action + " failed: " + (err.message || err), true);
1566
- if (btn) { setTimeout(() => { btn.disabled = false; btn.textContent = originalLabel; btn.style.opacity = ""; btn.style.background = ""; }, 2500); }
1567
- }
1563
+ } catch (err) { supervisorFeedback("Surface " + action + " failed: " + (err.message || err), true); }
1568
1564
  }
1569
1565
 
1570
1566
  // ── Unlock ──────────────────────────────────
@@ -1645,63 +1641,24 @@ async function lockVault() {
1645
1641
  showLockScreen(r.hard_locked || false);
1646
1642
  }
1647
1643
 
1648
- // -- Unlock writes (re-establish write scope without locking) --
1644
+ // ── Unlock writes (re-establish write scope without locking) ──
1649
1645
  // Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
1650
- // unlock screen, so it holds no write token. POST /auth mints one (30-min TTL).
1651
- //
1652
- // Uses an in-page modal rather than window.prompt()/confirm()/alert(): those
1653
- // native dialogs silently no-op in embedded/webview browser contexts (VS Code
1654
- // Simple Browser, WebView2, Electron wrappers) and after a real browser
1655
- // suppresses repeated dialogs for a tab ("prevent this page from creating
1656
- // additional dialogs") -- both cases fail with zero visible feedback.
1657
- function unlockWrites() {
1658
- if (writeToken) {
1659
- if (!confirm("Writes are already unlocked this session. Re-unlock?")) return;
1660
- }
1661
- openWriteUnlockModal();
1662
- }
1663
-
1664
- function openWriteUnlockModal() {
1665
- const overlay = document.getElementById("write-unlock-overlay");
1666
- const input = document.getElementById("write-unlock-input");
1667
- const err = document.getElementById("write-unlock-err");
1668
- if (err) err.textContent = "";
1669
- if (input) { input.value = ""; input.className = "lock-input"; }
1670
- if (overlay) overlay.style.display = "flex";
1671
- if (input) setTimeout(() => input.focus(), 50);
1672
- }
1673
-
1674
- function closeWriteUnlockModal() {
1675
- const overlay = document.getElementById("write-unlock-overlay");
1676
- if (overlay) overlay.style.display = "none";
1677
- }
1678
-
1679
- async function submitWriteUnlock() {
1680
- const input = document.getElementById("write-unlock-input");
1681
- const btn = document.getElementById("write-unlock-btn");
1682
- const err = document.getElementById("write-unlock-err");
1683
- const pw = input ? input.value : "";
1684
- if (!pw) { if (err) err.textContent = "Password is required."; return; }
1685
- if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
1646
+ // unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
1647
+ async function unlockWrites() {
1648
+ if (writeToken && !confirm("Writes are already unlocked this session. Re-unlock?")) return;
1649
+ const pw = prompt("Enter your vault password to enable saving changes (10-minute write session):");
1650
+ if (!pw) return;
1686
1651
  try {
1687
1652
  const r = await fetch(BASE + "/auth", {
1688
1653
  method: "POST",
1689
1654
  headers: { "Content-Type": "application/json" },
1690
1655
  body: JSON.stringify({ password: pw }),
1691
1656
  }).then(r => r.json());
1692
- if (r.error) {
1693
- if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
1694
- if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
1695
- return;
1696
- }
1657
+ if (r.error) { alert("Unlock failed: " + r.error); return; }
1697
1658
  writeToken = r.write_token || null;
1698
1659
  refreshWriteLockUi();
1699
- closeWriteUnlockModal();
1700
- } catch (e) {
1701
- if (err) err.textContent = "Unlock error: " + (e.message || e);
1702
- } finally {
1703
- if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
1704
- }
1660
+ alert(writeToken ? "Writes unlocked for 10 minutes." : "Unlock did not return a write token.");
1661
+ } catch (e) { alert("Unlock error: " + (e.message || e)); }
1705
1662
  }
1706
1663
 
1707
1664
  // Reflect write-lock state on the button so it is obvious when a save will fail.