@lifeaitools/clauth 2.1.1 → 2.10.1

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 +2618 -11238
  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 +86 -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
@@ -5,20 +5,53 @@ import path from 'node:path';
5
5
  import vm from 'node:vm';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
- import { dashboardHtml } from './commands/serve.js';
8
+ import { dashboardHtml, stripControlBytes } from './commands/serve.js';
9
9
 
10
10
  const here = path.dirname(fileURLToPath(import.meta.url));
11
11
  const serveSource = fs.readFileSync(path.join(here, 'commands', 'serve.js'), 'utf8');
12
+ // /write-token, /auth, /lock, /change-pw moved into their own file (the Vault/
13
+ // Auth component) — static-analysis assertions about that server-side gate
14
+ // logic read this instead of serveSource.
15
+ const vaultAuthComponentSource = fs.readFileSync(path.join(here, 'http', 'components', 'vault-auth-component.js'), 'utf8');
16
+ // The dashboard client script is a static asset (../dashboard/dashboard.js),
17
+ // not inlined in serve.js — static-analysis assertions about client-side code
18
+ // (write-action registry, write-guard install, etc.) read this instead.
19
+ const dashboardJsSource = fs.readFileSync(path.join(here, 'dashboard', 'dashboard.js'), 'utf8');
20
+ // Panel components load as their own classic <script> BEFORE dashboard.js
21
+ // (panel-componentization work) — the harness must replay that same order so
22
+ // panel functions are real window globals by the time dashboard.js's own boot
23
+ // code (e.g. pollTunnel()) calls into them, exactly like the served page.
24
+ // panel-element.js defines the ClauthPanelElement base class every real
25
+ // component panel (clauth-tunnel-panel, etc.) extends -- it must load and
26
+ // evaluate before any panel script that does `extends window.ClauthPanelElement`.
27
+ const panelElementJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panel-element.js'), 'utf8');
28
+ const panelRegistryJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'panel-registry.js'), 'utf8');
29
+ const tunnelPanelJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'tunnel-panel.js'), 'utf8');
30
+ const toolbarPanelJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'toolbar-panel.js'), 'utf8');
31
+ const webdavPanelJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'webdav-panel.js'), 'utf8');
32
+ const servicesPanelJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'services-panel.js'), 'utf8');
33
+ const buildStatusPanelJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'build-status-panel.js'), 'utf8');
34
+ const surfacesPanelJsSource = fs.readFileSync(path.join(here, 'dashboard', 'panels', 'surfaces-panel.js'), 'utf8');
35
+ // Write-action static analysis (registeredWriteActions/writeHeaderCallers)
36
+ // must see every panel component's function bodies, not just dashboard.js's
37
+ // own — a registered action's implementation can now live in a panel file
38
+ // (e.g. wizSubmitCfToken moved to tunnel-panel.js). The choke-point mechanism
39
+ // itself (WRITE_ACTIONS array, installWriteAccessGuards, pendingWriteAction,
40
+ // the acquire site) stays in dashboard.js and is checked there directly.
41
+ const allClientSource = dashboardJsSource + '\n' + panelElementJsSource + '\n' + panelRegistryJsSource + '\n' + tunnelPanelJsSource + '\n' + toolbarPanelJsSource + '\n' + webdavPanelJsSource + '\n' + servicesPanelJsSource + '\n' + buildStatusPanelJsSource + '\n' + surfacesPanelJsSource;
12
42
 
13
43
  // ─────────────────────────────────────────────────────────────────────────────
14
44
  // Functional harness
15
45
  //
16
- // The dashboard JS ships inside a served HTML template, so there is nothing to
17
- // import. This harness renders the real page with dashboardHtml(), lifts its
18
- // script into a vm realm with a stub DOM + stub fetch, and then calls the real
19
- // controller seams (runSupervisorSurface, addService, submitWriteUnlock, …)
20
- // with simulated inputs per the global UI functional-harness rule. No pixels,
21
- // no browser automation: assertions are on state, command and payload.
46
+ // The dashboard JS ships as a static asset served by GET /dashboard.js, with a
47
+ // tiny per-request inline preamble (dashboardHtml()) setting
48
+ // window.__CLAUTH_VERSION__ / window.__CLAUTH_INIT_WRITE_TOKEN__ the only
49
+ // two values that vary per request. This harness reconstructs exactly that
50
+ // concatenation (preamble + static script) in a vm realm with a stub DOM +
51
+ // stub fetch, then calls the real controller seams (runSupervisorSurface,
52
+ // addService, submitWriteUnlock, …) with simulated inputs — per the global UI
53
+ // functional-harness rule. No pixels, no browser automation: assertions are
54
+ // on state, command and payload.
22
55
  //
23
56
  // The requirement these tests encode: a dashboard button IS the human being
24
57
  // present. On an unlocked vault a click must proceed with no password prompt.
@@ -52,25 +85,65 @@ function makeElement(id) {
52
85
  focus() {},
53
86
  click() {},
54
87
  querySelectorAll: () => [],
88
+ // Real dashboard code reaches for these; a stub that lacks them fails as
89
+ // "x is not a function" from inside the code under test, which reads like a
90
+ // product bug and is not one.
91
+ querySelector: () => makeElement(id + '-child'),
92
+ remove() {},
55
93
  };
56
94
  }
57
95
 
58
96
  function bootDashboard({ initWriteToken = null, route } = {}) {
97
+ // Faithful to production: real page load is the tiny inline preamble
98
+ // (dashboardHtml()) running first, then the static dashboard.js. Assert the
99
+ // real page still emits that preamble rather than assuming its shape.
59
100
  const html = dashboardHtml(53999, [], false, initWriteToken);
60
- const start = html.indexOf('<script>');
61
- const end = html.lastIndexOf('</script>');
62
- assert.ok(start !== -1 && end > start, 'dashboard HTML must contain exactly one script block');
63
- const script = html.slice(start + '<script>'.length, end);
101
+ assert.match(
102
+ html,
103
+ /<script src="\/dashboard\/panel-element\.js"><\/script>\s*<script src="\/dashboard\/panels\/panel-registry\.js"><\/script>\s*<script src="\/dashboard\/panels\/tunnel-panel\.js"><\/script>\s*<script src="\/dashboard\/panels\/toolbar-panel\.js"><\/script>\s*<script src="\/dashboard\/panels\/webdav-panel\.js"><\/script>\s*<script src="\/dashboard\/panels\/services-panel\.js"><\/script>\s*<script src="\/dashboard\/panels\/build-status-panel\.js"><\/script>\s*<script src="\/dashboard\/panels\/surfaces-panel\.js"><\/script>\s*<script src="\/dashboard\.js"><\/script>/,
104
+ 'dashboard HTML must load panel components before the main client script',
105
+ );
106
+ const preambleStart = html.indexOf('<script>');
107
+ const preambleEnd = html.indexOf('</script>');
108
+ assert.ok(preambleStart !== -1 && preambleEnd > preambleStart, 'dashboard HTML must contain its inline init preamble');
109
+ const preamble = html.slice(preambleStart + '<script>'.length, preambleEnd);
110
+ const script = preamble + '\n' + panelElementJsSource + '\n' + panelRegistryJsSource + '\n' + tunnelPanelJsSource + '\n' + toolbarPanelJsSource + '\n' + webdavPanelJsSource + '\n' + servicesPanelJsSource + '\n' + buildStatusPanelJsSource + '\n' + surfacesPanelJsSource + '\n' + dashboardJsSource;
64
111
 
65
112
  const elements = new Map();
66
113
  const fetchCalls = [];
67
114
  const confirmCalls = [];
115
+ // Real component panels register their class under customElements.define(),
116
+ // then a bridge like `window.runSupervisorSurface = () => document
117
+ // .querySelector("clauth-surfaces-panel")?.runSupervisorSurface(...)` is
118
+ // what every registered write action now dispatches through (Shadow DOM
119
+ // means installWriteAccessGuards can only wrap a real global function, not
120
+ // an instance method — see each panel file's own header comment for the
121
+ // bridge pattern). document.querySelector must therefore hand back a live
122
+ // instance of the matching class, not null, or every bridge silently no-ops
123
+ // and these tests would pass by testing nothing.
124
+ //
125
+ // Lazily instantiated + connectedCallback()'d (structural: builds the fake
126
+ // shadow root so this.$()/this.$$() resolve), one singleton per tag name —
127
+ // matching production, where exactly one of each panel tag exists in the
128
+ // page. mount() is deliberately NOT called: none of the write-action methods
129
+ // under test here depend on mount()-installed instance state, and skipping
130
+ // it avoids every panel's real polling/initial-fetch side effects leaking
131
+ // into these tests' fetchCalls tracking.
132
+ const customElementClasses = new Map();
133
+ const customElementInstances = new Map();
68
134
 
69
135
  const sandbox = {
70
136
  console: { log() {}, warn() {}, error() {} },
71
137
  JSON,
72
138
  Date,
73
139
  Math,
140
+ // A browser realm has these. Omitting URL made every new URL(...) throw
141
+ // ReferenceError inside the dashboard's own try/catch, so url-handling code
142
+ // silently took its fallback path and the test asserted the fallback.
143
+ URL,
144
+ URLSearchParams,
145
+ encodeURIComponent,
146
+ decodeURIComponent,
74
147
  setTimeout: () => 0,
75
148
  clearTimeout: () => {},
76
149
  setInterval: () => 0,
@@ -79,12 +152,42 @@ function bootDashboard({ initWriteToken = null, route } = {}) {
79
152
  navigator: { clipboard: { writeText: async () => {} } },
80
153
  confirm: (msg) => { confirmCalls.push(msg); return true; },
81
154
  alert: () => {},
155
+ // Real component panels (clauth-tunnel-panel, etc.) extend HTMLElement
156
+ // and call customElements.define() at module scope -- both must exist
157
+ // for those class declarations to evaluate without throwing. attachShadow()
158
+ // returns a generic DOM-node stub AND assigns it to this.shadowRoot --
159
+ // real browsers do that assignment as a side effect of attachShadow(),
160
+ // and panel-element.js's connectedCallback()/$()/$$() rely on it existing.
161
+ HTMLElement: class {
162
+ attachShadow() {
163
+ const root = makeElement('shadow-root');
164
+ this.shadowRoot = root;
165
+ return root;
166
+ }
167
+ },
168
+ customElements: {
169
+ define(tag, cls) { customElementClasses.set(tag, cls); },
170
+ },
82
171
  document: {
83
172
  getElementById(id) {
84
173
  if (!elements.has(id)) elements.set(id, makeElement(id));
85
174
  return elements.get(id);
86
175
  },
87
176
  createElement: (tag) => makeElement(tag),
177
+ // See customElementClasses/customElementInstances above -- a matching
178
+ // tag name lazily instantiates and structurally builds the real class,
179
+ // so a write-action bridge reaches real instance logic instead of a
180
+ // silent `el?.method()` no-op on null.
181
+ querySelector(selector) {
182
+ const cls = customElementClasses.get(selector);
183
+ if (!cls) return null;
184
+ if (!customElementInstances.has(selector)) {
185
+ const instance = new cls();
186
+ instance.connectedCallback();
187
+ customElementInstances.set(selector, instance);
188
+ }
189
+ return customElementInstances.get(selector);
190
+ },
88
191
  querySelectorAll: () => [],
89
192
  addEventListener() {},
90
193
  body: makeElement('body'),
@@ -299,7 +402,12 @@ test('locked vault: cancelling clears the parked action, and no write fires late
299
402
  assert.equal(h.ctx.hasPendingWriteAction(), false, 'cancel must disarm the parked write');
300
403
  assert.deepEqual(h.writeCalls(), []);
301
404
 
302
- h.ctx.unlockWrites();
405
+ // unlockWrites() is dispatched via the toolbar panel's own data-action
406
+ // (see toolbar-panel.js) with no bare global bridge -- it's not a write
407
+ // action itself (never calls writeHeaders()), so installWriteAccessGuards
408
+ // never needed one. Reach the real instance directly, same as production's
409
+ // data-action click would.
410
+ h.ctx.document.querySelector('clauth-toolbar-panel').unlockWrites();
303
411
  h.el('write-unlock-input').value = 'correct-horse';
304
412
  await h.ctx.submitWriteUnlock();
305
413
  await h.settle();
@@ -323,11 +431,11 @@ const EXPECTED_WRITE_ACTIONS = [
323
431
  'deleteService',
324
432
  'enrollMachine',
325
433
  'rescanSupervisorPlugins',
434
+ 'restartDaemon',
326
435
  'rotateKey',
327
436
  'runSupervisorSurface',
437
+ 'saveCardEdit',
328
438
  'saveKey',
329
- 'saveLabel',
330
- 'saveProject',
331
439
  'setExpiry',
332
440
  'submitMount',
333
441
  'toggleService',
@@ -353,7 +461,16 @@ function registeredWriteActions(source) {
353
461
  * Comment lines are skipped: prose mentioning writeHeaders() is not a call
354
462
  * site, and counting it as one produced a false "bypasses the choke point"
355
463
  * failure the first time a doc comment landed after a function declaration.
464
+ *
465
+ * Also recognises ES6 class-method shorthand (`methodName() {` /
466
+ * `async methodName() {`) -- the real-component panel rewrite (clauth-tunnel-
467
+ * panel and its Web Component siblings) moved several registered write
468
+ * actions (e.g. wizSubmitCfToken) from top-level function declarations into
469
+ * class methods on a real component. Common control-flow keywords
470
+ * (if/for/while/switch/catch/function/return) are excluded so `if (x) {`
471
+ * doesn't get misread as a method named "if".
356
472
  */
473
+ const JS_CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'else']);
357
474
  function writeHeaderCallers(source) {
358
475
  const callers = new Set();
359
476
  let current = null;
@@ -362,8 +479,10 @@ function writeHeaderCallers(source) {
362
479
  const bound = line.match(
363
480
  /^(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:function\b|\(|[A-Za-z0-9_$]+\s*=>)/,
364
481
  );
482
+ const method = line.match(/^\s+(?:async\s+)?(?:\*\s*)?(?:get\s+|set\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*\{?\s*$/);
365
483
  if (decl) current = decl[1];
366
484
  else if (bound) current = bound[1];
485
+ else if (method && !JS_CONTROL_KEYWORDS.has(method[1])) current = method[1];
367
486
 
368
487
  const code = line.trim();
369
488
  if (code.startsWith('//') || code.startsWith('*') || code.startsWith('/*')) continue;
@@ -375,7 +494,7 @@ function writeHeaderCallers(source) {
375
494
  }
376
495
 
377
496
  test('WRITE_ACTIONS matches the reviewed write-action surface exactly', () => {
378
- const registered = [...registeredWriteActions(serveSource)].sort();
497
+ const registered = [...registeredWriteActions(dashboardJsSource)].sort();
379
498
  assert.deepEqual(
380
499
  registered,
381
500
  EXPECTED_WRITE_ACTIONS,
@@ -384,8 +503,8 @@ test('WRITE_ACTIONS matches the reviewed write-action surface exactly', () => {
384
503
  });
385
504
 
386
505
  test('WRITE_ACTIONS covers every function that calls writeHeaders()', () => {
387
- const registered = registeredWriteActions(serveSource);
388
- const callers = writeHeaderCallers(serveSource);
506
+ const registered = registeredWriteActions(dashboardJsSource);
507
+ const callers = writeHeaderCallers(allClientSource);
389
508
 
390
509
  const unguarded = [...callers].filter((n) => !registered.has(n));
391
510
  assert.deepEqual(unguarded, [], 'these write actions bypass the choke point: ' + unguarded.join(', '));
@@ -398,7 +517,7 @@ test('WRITE_ACTIONS covers every function that calls writeHeaders()', () => {
398
517
 
399
518
  test('every registered write action is actually wrapped at install time', () => {
400
519
  const h = bootDashboard({ initWriteToken: 'wt-preexisting', route: unlockedRoute });
401
- const registered = [...registeredWriteActions(serveSource)];
520
+ const registered = [...registeredWriteActions(dashboardJsSource)];
402
521
 
403
522
  const notWrapped = registered.filter((name) => h.ctx[name]?.__writeGuarded !== true);
404
523
  assert.deepEqual(
@@ -410,33 +529,41 @@ test('every registered write action is actually wrapped at install time', () =>
410
529
  });
411
530
 
412
531
  test('installWriteAccessGuards throws on a registered name that cannot be guarded', () => {
413
- assert.match(serveSource, /clauth write-guard install failed/);
532
+ assert.match(dashboardJsSource, /clauth write-guard install failed/);
414
533
  assert.doesNotMatch(
415
- serveSource,
534
+ dashboardJsSource,
416
535
  /if \(typeof fn !== "function" \|\| fn\.__writeGuarded\) continue;/,
417
536
  'the old silent-skip guard must not come back',
418
537
  );
419
538
  });
420
539
 
421
540
  test('the write-access choke point is a single mechanism, not per-call-site guards', () => {
422
- const parkSites = [...serveSource.matchAll(/pendingWriteAction = \{/g)];
541
+ const parkSites = [...dashboardJsSource.matchAll(/pendingWriteAction = \{/g)];
423
542
  assert.equal(parkSites.length, 1, 'exactly one place may park a write action');
424
- const acquireSites = [...serveSource.matchAll(/BASE \+ "\/write-token"/g)];
543
+ const acquireSites = [...dashboardJsSource.matchAll(/BASE \+ "\/write-token"/g)];
425
544
  assert.equal(acquireSites.length, 1, 'exactly one place may acquire write access');
426
- assert.match(serveSource, /function installWriteAccessGuards\(/);
545
+ assert.match(dashboardJsSource, /function installWriteAccessGuards\(/);
427
546
  });
428
547
 
429
548
  // ── 4. Server contract: the gate stays, the prompt goes ──────────────────────
430
549
 
431
550
  test('POST /write-token refuses a locked vault and never weakens the write gate', () => {
432
- assert.match(serveSource, /reqPath === "\/write-token"/, 'the acquire route must exist');
551
+ // /write-token/, /auth, /lock, /change-pw moved into their own file (the
552
+ // Vault/Auth component, registered via registerWriteRoute — a Map lookup,
553
+ // not an inline "reqPath ===" check, hence the different regex shape here).
554
+ assert.match(
555
+ vaultAuthComponentSource,
556
+ /registerWriteRoute\("POST", "\/write-token"/,
557
+ 'the acquire route must exist',
558
+ );
433
559
  assert.match(
434
- serveSource,
435
- /reqPath === "\/write-token"\)\s*\{\s*\n\s*if \(!password\) \{/,
560
+ vaultAuthComponentSource,
561
+ /registerWriteRoute\("POST", "\/write-token", async \(req, res\) => \{\s*\n\s*if \(!ctx\.password\) \{/,
436
562
  'a locked vault must be refused before any token is minted',
437
563
  );
438
564
  // writeGuard is what makes a page-driven write distinguishable from a blind
439
- // remote POST arriving through the cloudflared tunnel as loopback. It stays.
565
+ // remote POST arriving through the cloudflared tunnel as loopback. It stays
566
+ // defined in serve.js (passed to components via ctx as a function reference).
440
567
  assert.match(serveSource, /function writeGuard\(req, res\) \{[\s\S]*?validateWriteToken\(req, writeSession\)/);
441
568
  assert.match(serveSource, /if \(!isLocal\) \{[\s\S]*?strike\(res, 403/, 'non-loopback must stay hard-rejected');
442
569
  });
@@ -445,8 +572,81 @@ test('POST /write-token refuses a locked vault and never weakens the write gate'
445
572
 
446
573
  test('clauth supervisor UI exposes every lifecycle action and receipt feedback', () => {
447
574
  for (const action of ['start', 'stop', 'restart', 'reconcile', 'test', 'promote', 'rollback']) {
448
- assert.match(serveSource, new RegExp('"' + action + '"'));
575
+ assert.match(surfacesPanelJsSource, new RegExp('"' + action + '"'));
449
576
  }
450
- assert.match(serveSource, /supervisorFeedback\(/);
451
- assert.match(serveSource, /supervisor-feedback/);
577
+ assert.match(surfacesPanelJsSource, /supervisorFeedback\(/);
578
+ assert.match(surfacesPanelJsSource, /supervisor-feedback/);
579
+ });
580
+
581
+ // ── 5. Health status, focus refresh, and browsable Open targets ──────────────
582
+
583
+ test('browsableUrl strips MCP transport paths and leaves real paths alone', () => {
584
+ const { ctx } = bootDashboard();
585
+ // The defect: clauth's own Open button opened https://clauth.../mcp and the
586
+ // browser rendered the raw SSE handshake — "event: endpoint / data: ...".
587
+ assert.equal(ctx.browsableUrl('https://clauth.regendevcorp.com/mcp'), 'https://clauth.regendevcorp.com/');
588
+ assert.equal(ctx.browsableUrl('https://media.regendevcorp.com/sse'), 'https://media.regendevcorp.com/');
589
+ assert.equal(ctx.browsableUrl('https://x.example.com/message?sessionId=ses_1'), 'https://x.example.com/');
590
+ // A genuine application path is NOT a transport path and must survive.
591
+ assert.equal(ctx.browsableUrl('https://fs.regendevcorp.com/fs'), 'https://fs.regendevcorp.com/fs');
592
+ assert.equal(ctx.browsableUrl('http://127.0.0.1:3003/'), 'http://127.0.0.1:3003/');
593
+ // Never throws on junk — the Open button must not take the panel down.
594
+ assert.equal(ctx.browsableUrl('not a url'), 'not a url');
595
+ assert.equal(ctx.browsableUrl(null), null);
596
+ });
597
+
598
+ test('a surface card renders a health pill distinct from its manifest state', () => {
599
+ const { ctx } = bootDashboard();
600
+ // renderSupervisorSurface/refreshSurfaceHealth are instance methods on
601
+ // ClauthSurfacesPanel now (Shadow DOM conversion) with no bare global
602
+ // bridge -- neither calls writeHeaders(), so installWriteAccessGuards
603
+ // never needed one. Reach the real instance directly.
604
+ const surfacesPanel = ctx.document.querySelector('clauth-surfaces-panel');
605
+ const base = { plugin_id: 'demo', id: 'primary', name: 'demo', destination: 'local/clauth/pm2', lifecycle_owner: 'clauth', port: 3110, health: '/health', state: 'current' };
606
+
607
+ const up = surfacesPanel.renderSupervisorSurface({ ...base, last_health_ok: true, last_health_at: new Date().toISOString() });
608
+ assert.match(up, /up ·/, 'a healthy surface must show an up pill');
609
+
610
+ // The point of the whole feature: state stays "current" (a manifest fact)
611
+ // while health says DOWN (a runtime fact). If the card only showed state,
612
+ // a surface that is not listening at all still reads "current".
613
+ const down = surfacesPanel.renderSupervisorSurface({ ...base, last_health_ok: false, last_health_at: new Date().toISOString(), last_health_error: 'ECONNREFUSED' });
614
+ assert.match(down, /DOWN ·/, 'an unreachable surface must show a DOWN pill');
615
+ assert.match(down, /current/, 'manifest state must still be shown alongside health');
616
+ assert.match(down, /ECONNREFUSED/, 'the failure reason belongs on the card');
617
+
618
+ const never = surfacesPanel.renderSupervisorSurface(base);
619
+ assert.match(never, /health unknown/, 'an unprobed surface must not claim to be up');
620
+ });
621
+
622
+ test('the health sweep carries NO write token — looking at the tab must never prompt to unlock', async () => {
623
+ const { ctx, fetchCalls, writeCalls, settle } = bootDashboard({
624
+ route: (url) => {
625
+ if (url.endsWith('/v1/surfaces/health')) {
626
+ return { probed: [{ surface_id: 'demo:primary', health: 'healthy', url: 'http://127.0.0.1:3110/health' }] };
627
+ }
628
+ if (url.endsWith('/write-token')) return { ok: true, write_token: 'wt-fresh' };
629
+ return {};
630
+ },
631
+ });
632
+ await ctx.document.querySelector('clauth-surfaces-panel').refreshSurfaceHealth({ quiet: false });
633
+ await settle();
634
+ const sweep = fetchCalls.filter((c) => c.url.endsWith('/v1/surfaces/health'));
635
+ assert.equal(sweep.length, 1, 'the sweep must have run — otherwise the assertions below prove nothing');
636
+ assert.equal(sweep[0].options.method, 'POST');
637
+ assert.equal(
638
+ writeCalls().filter((c) => c.url.endsWith('/v1/surfaces/health')).length, 0,
639
+ 'the read-only health sweep must not send a write token, or a focus event would prompt for the vault password',
640
+ );
641
+ });
642
+
643
+ test('stripControlBytes removes ANSI colour and stray control bytes, keeping the text', () => {
644
+ const ESC = String.fromCharCode(27);
645
+ const coloured = ESC + '[32m✔ ready' + ESC + '[39m' + String.fromCharCode(1) + ' done';
646
+ assert.equal(stripControlBytes(coloured), '✔ ready done');
647
+ // Structure survives: tab, LF and CR are the log's own formatting.
648
+ assert.equal(stripControlBytes('a\tb\nc\r\nd'), 'a\tb\nc\r\nd');
649
+ // Positive control: a string with nothing to strip comes back byte-identical,
650
+ // so "it was cleaned" is distinguishable from "the function ate everything".
651
+ assert.equal(stripControlBytes('plain ASCII line 42'), 'plain ASCII line 42');
452
652
  });
@@ -0,0 +1,84 @@
1
+ // cli/webdav-config.js
2
+ // WebDAV mount config persistence (CRUD over a JSON file). Split out of
3
+ // webdav-service.js -- the SOLID scorer flagged that file's original shape
4
+ // (74.8 total) as mixing this config-CRUD concern with the rclone child
5
+ // supervisor, the rclone-obscure crypto helper, and rclone binary discovery.
6
+
7
+ import { writeFileSync, readFileSync, mkdirSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { join, dirname } from "path";
10
+
11
+ export const WEBDAV_PORT = 8620;
12
+ export const CRED_NAME = "webdav-claude";
13
+
14
+ const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
15
+ // A FUNCTION, not a top-level const: this module is imported once and its
16
+ // top-level bindings evaluate at that moment, so a const here would freeze
17
+ // whatever CLAUTH_WEBDAV_CONFIG_PATH was (or wasn't) set to at import time —
18
+ // a test setting the env var per-case afterward would have no effect at all.
19
+ // Same override pattern as recovery.js's CLAUTH_RECOVERY_DIR and
20
+ // supervisor-registry.js's CLAUTH_SUPERVISOR_DIR; without it,
21
+ // loadConfig/saveConfig/addMount/removeMount/updateMount all read and write
22
+ // Dave's real AppData mount config during a test run.
23
+ export function getConfigPath() {
24
+ return process.env.CLAUTH_WEBDAV_CONFIG_PATH || join(APPDATA, "clauth", "webdav-config.json");
25
+ }
26
+
27
+ const DEFAULT_CONFIG = {
28
+ enabled: true,
29
+ port: WEBDAV_PORT,
30
+ credential: CRED_NAME,
31
+ upstreams: [],
32
+ };
33
+
34
+ export function loadConfig() {
35
+ try {
36
+ const raw = readFileSync(getConfigPath(), "utf8");
37
+ const cfg = JSON.parse(raw);
38
+ if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
39
+ return { ...DEFAULT_CONFIG, ...cfg };
40
+ } catch {
41
+ // `{ ...DEFAULT_CONFIG }` alone is a shallow copy — `upstreams` would be
42
+ // the SAME array object as DEFAULT_CONFIG.upstreams, not a fresh one.
43
+ // addMount() does `cfg.upstreams.push(...)` on whatever loadConfig hands
44
+ // back; on this fallback path (no config file yet, or a corrupted one)
45
+ // that push would mutate the shared module-level DEFAULT_CONFIG.upstreams
46
+ // itself, permanently, for the rest of the process. Every later call
47
+ // reaching this SAME catch branch — a fresh install with no file yet, or
48
+ // any future read of a corrupted file — would then return whatever got
49
+ // pushed here before, not a true empty default.
50
+ return { ...DEFAULT_CONFIG, upstreams: [] };
51
+ }
52
+ }
53
+
54
+ export function saveConfig(cfg) {
55
+ const configPath = getConfigPath();
56
+ mkdirSync(dirname(configPath), { recursive: true });
57
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2), "utf8");
58
+ }
59
+
60
+ export function addMount(name, mountPath) {
61
+ const cfg = loadConfig();
62
+ if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
63
+ cfg.upstreams.push({ name, path: mountPath });
64
+ saveConfig(cfg);
65
+ return { ok: true, upstreams: cfg.upstreams };
66
+ }
67
+
68
+ export function removeMount(name) {
69
+ const cfg = loadConfig();
70
+ const before = cfg.upstreams.length;
71
+ cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
72
+ if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
73
+ saveConfig(cfg);
74
+ return { ok: true, upstreams: cfg.upstreams };
75
+ }
76
+
77
+ export function updateMount(name, updates) {
78
+ const cfg = loadConfig();
79
+ const mount = cfg.upstreams.find(u => u.name === name);
80
+ if (!mount) return { error: `Mount '${name}' not found` };
81
+ if (updates.credential !== undefined) cfg.credential = updates.credential;
82
+ saveConfig(cfg);
83
+ return { ok: true, upstreams: cfg.upstreams };
84
+ }
@@ -0,0 +1,79 @@
1
+ import test from "node:test";
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
+
7
+ import { loadConfig, addMount, removeMount, updateMount } from "./webdav-config.js";
8
+
9
+ function withIsolatedConfig(fn) {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-webdav-config-test-"));
11
+ const configPath = path.join(dir, "webdav-config.json");
12
+ const old = process.env.CLAUTH_WEBDAV_CONFIG_PATH;
13
+ process.env.CLAUTH_WEBDAV_CONFIG_PATH = configPath;
14
+ try { return fn(configPath); } finally {
15
+ if (old === undefined) Reflect.deleteProperty(process.env, "CLAUTH_WEBDAV_CONFIG_PATH");
16
+ else process.env.CLAUTH_WEBDAV_CONFIG_PATH = old;
17
+ fs.rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ }
20
+
21
+ test("loadConfig returns sane defaults when no config file exists yet", () =>
22
+ withIsolatedConfig(() => {
23
+ const cfg = loadConfig();
24
+ assert.equal(cfg.enabled, true);
25
+ assert.deepEqual(cfg.upstreams, []);
26
+ assert.equal(cfg.credential, "webdav-claude");
27
+ }));
28
+
29
+ test("addMount persists a new upstream and rejects a duplicate name", () =>
30
+ withIsolatedConfig((configPath) => {
31
+ const first = addMount("projects", "C:\\Dev\\regen-root");
32
+ assert.equal(first.ok, true);
33
+ assert.deepEqual(first.upstreams, [{ name: "projects", path: "C:\\Dev\\regen-root" }]);
34
+ assert.ok(fs.existsSync(configPath), "saveConfig must actually write the file");
35
+
36
+ const dup = addMount("projects", "C:\\Dev\\somewhere-else");
37
+ assert.match(dup.error, /already exists/);
38
+ // The duplicate attempt must not have overwritten the original entry.
39
+ assert.equal(loadConfig().upstreams.length, 1);
40
+ }));
41
+
42
+ test("removeMount deletes the named upstream and reports not-found for an unknown one", () =>
43
+ withIsolatedConfig(() => {
44
+ addMount("projects", "C:\\Dev\\regen-root");
45
+ addMount("corpus", "C:\\rdc-gdrive\\global-corpus");
46
+ const removed = removeMount("projects");
47
+ assert.equal(removed.ok, true);
48
+ assert.deepEqual(removed.upstreams.map((u) => u.name), ["corpus"]);
49
+
50
+ const missing = removeMount("projects");
51
+ assert.match(missing.error, /not found/);
52
+ }));
53
+
54
+ test("updateMount changes the credential and reports not-found for an unknown mount", () =>
55
+ withIsolatedConfig(() => {
56
+ addMount("projects", "C:\\Dev\\regen-root");
57
+ const updated = updateMount("projects", { credential: "webdav-alt-cred" });
58
+ assert.equal(updated.ok, true);
59
+ assert.equal(loadConfig().credential, "webdav-alt-cred");
60
+
61
+ const missing = updateMount("does-not-exist", { credential: "x" });
62
+ assert.match(missing.error, /not found/);
63
+ }));
64
+
65
+ test("loadConfig tolerates a corrupt config file by falling back to defaults", () =>
66
+ withIsolatedConfig((configPath) => {
67
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
68
+ fs.writeFileSync(configPath, "{ not valid json", "utf8");
69
+ const cfg = loadConfig();
70
+ assert.deepEqual(cfg.upstreams, []);
71
+ assert.equal(cfg.enabled, true);
72
+ }));
73
+
74
+ test("loadConfig coerces a non-array upstreams field instead of propagating a broken shape", () =>
75
+ withIsolatedConfig((configPath) => {
76
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
77
+ fs.writeFileSync(configPath, JSON.stringify({ upstreams: "not-an-array" }), "utf8");
78
+ assert.deepEqual(loadConfig().upstreams, []);
79
+ }));
@@ -0,0 +1,23 @@
1
+ // cli/webdav-obscure.js
2
+ // rclone-compatible AES-CTR "obscure" -- same algorithm as `rclone obscure`.
3
+ // Used by fs_dav_setup to embed a non-plaintext credential in the mount
4
+ // script. The result can be passed as RCLONE_CONFIG_xxx_PASS. Split out of
5
+ // webdav-service.js -- the SOLID scorer flagged that file's original shape
6
+ // as mixing this crypto helper with the rclone child supervisor, config
7
+ // persistence, and rclone binary discovery.
8
+
9
+ import crypto from "node:crypto";
10
+
11
+ export function rcloneObscure(password) {
12
+ // Key from rclone source: lib/obscure/obscure.go cryptKey
13
+ const key = Buffer.from([
14
+ 0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
15
+ 0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
16
+ 0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
17
+ 0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
18
+ ]);
19
+ const iv = crypto.randomBytes(16);
20
+ const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
21
+ const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
22
+ return Buffer.concat([iv, ct]).toString("base64url");
23
+ }
@@ -0,0 +1,41 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import crypto from "node:crypto";
4
+
5
+ import { rcloneObscure } from "./webdav-obscure.js";
6
+
7
+ // rclone's own decrypt (same key, AES-256-CTR, IV prefixed to ciphertext) —
8
+ // reimplemented here purely to round-trip verify. Asserting only "looks like
9
+ // base64url" would pass even if the cipher, key, or IV placement were wrong;
10
+ // this proves the actual bytes rclone would receive decrypt back to the input.
11
+ function rcloneUnobscure(obscured) {
12
+ const key = Buffer.from([
13
+ 0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
14
+ 0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
15
+ 0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
16
+ 0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
17
+ ]);
18
+ const raw = Buffer.from(obscured, "base64url");
19
+ const iv = raw.subarray(0, 16);
20
+ const ct = raw.subarray(16);
21
+ const decipher = crypto.createDecipheriv("aes-256-ctr", key, iv);
22
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
23
+ }
24
+
25
+ test("rcloneObscure round-trips through rclone's own obscure algorithm", () => {
26
+ const obscured = rcloneObscure("hunter2-password");
27
+ assert.equal(rcloneUnobscure(obscured), "hunter2-password");
28
+ });
29
+
30
+ test("rcloneObscure never emits the plaintext password as a substring", () => {
31
+ const obscured = rcloneObscure("very-recognizable-secret-xyz");
32
+ assert.ok(!obscured.includes("very-recognizable-secret-xyz"));
33
+ });
34
+
35
+ test("rcloneObscure uses a fresh random IV each call — the same password never obscures identically twice", () => {
36
+ const a = rcloneObscure("same-password");
37
+ const b = rcloneObscure("same-password");
38
+ assert.notEqual(a, b, "identical output for identical input means the IV is not actually random");
39
+ assert.equal(rcloneUnobscure(a), "same-password");
40
+ assert.equal(rcloneUnobscure(b), "same-password");
41
+ });