@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.
- 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 +2618 -11238
- 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 +86 -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
|
@@ -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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
388
|
-
const callers = writeHeaderCallers(
|
|
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(
|
|
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(
|
|
532
|
+
assert.match(dashboardJsSource, /clauth write-guard install failed/);
|
|
414
533
|
assert.doesNotMatch(
|
|
415
|
-
|
|
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 = [...
|
|
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 = [...
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
/
|
|
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(
|
|
575
|
+
assert.match(surfacesPanelJsSource, new RegExp('"' + action + '"'));
|
|
449
576
|
}
|
|
450
|
-
assert.match(
|
|
451
|
-
assert.match(
|
|
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
|
+
});
|