@lifeaitools/clauth 2.0.0 → 2.0.2
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 +396 -396
- package/cli/commands/serve.js +174 -2
- package/cli/commands/uninstall.js +164 -164
- package/cli/index.js +73 -1
- package/cli/supervisor-registry.js +311 -2
- package/cli/supervisor-registry.test.js +926 -548
- package/cli/supervisor-ui.test.js +436 -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
|
@@ -2,11 +2,447 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import vm from 'node:vm';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
|
|
8
|
+
import { dashboardHtml } from './commands/serve.js';
|
|
9
|
+
|
|
7
10
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
11
|
const serveSource = fs.readFileSync(path.join(here, 'commands', 'serve.js'), 'utf8');
|
|
9
12
|
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Functional harness
|
|
15
|
+
//
|
|
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.
|
|
22
|
+
//
|
|
23
|
+
// The requirement these tests encode: a dashboard button IS the human being
|
|
24
|
+
// present. On an unlocked vault a click must proceed with no password prompt.
|
|
25
|
+
// The prompt exists only for a genuinely locked vault.
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function makeElement(id) {
|
|
29
|
+
const classes = new Set();
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
value: '',
|
|
33
|
+
textContent: '',
|
|
34
|
+
innerHTML: '',
|
|
35
|
+
className: '',
|
|
36
|
+
title: '',
|
|
37
|
+
disabled: false,
|
|
38
|
+
dataset: {},
|
|
39
|
+
style: {},
|
|
40
|
+
scrollHeight: 0,
|
|
41
|
+
scrollTop: 0,
|
|
42
|
+
clientHeight: 0,
|
|
43
|
+
classList: {
|
|
44
|
+
add: (c) => classes.add(c),
|
|
45
|
+
remove: (c) => classes.delete(c),
|
|
46
|
+
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
|
|
47
|
+
contains: (c) => classes.has(c),
|
|
48
|
+
},
|
|
49
|
+
addEventListener() {},
|
|
50
|
+
removeEventListener() {},
|
|
51
|
+
appendChild() {},
|
|
52
|
+
focus() {},
|
|
53
|
+
click() {},
|
|
54
|
+
querySelectorAll: () => [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bootDashboard({ initWriteToken = null, route } = {}) {
|
|
59
|
+
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);
|
|
64
|
+
|
|
65
|
+
const elements = new Map();
|
|
66
|
+
const fetchCalls = [];
|
|
67
|
+
const confirmCalls = [];
|
|
68
|
+
|
|
69
|
+
const sandbox = {
|
|
70
|
+
console: { log() {}, warn() {}, error() {} },
|
|
71
|
+
JSON,
|
|
72
|
+
Date,
|
|
73
|
+
Math,
|
|
74
|
+
setTimeout: () => 0,
|
|
75
|
+
clearTimeout: () => {},
|
|
76
|
+
setInterval: () => 0,
|
|
77
|
+
clearInterval: () => {},
|
|
78
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
79
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
80
|
+
confirm: (msg) => { confirmCalls.push(msg); return true; },
|
|
81
|
+
alert: () => {},
|
|
82
|
+
document: {
|
|
83
|
+
getElementById(id) {
|
|
84
|
+
if (!elements.has(id)) elements.set(id, makeElement(id));
|
|
85
|
+
return elements.get(id);
|
|
86
|
+
},
|
|
87
|
+
createElement: (tag) => makeElement(tag),
|
|
88
|
+
querySelectorAll: () => [],
|
|
89
|
+
addEventListener() {},
|
|
90
|
+
body: makeElement('body'),
|
|
91
|
+
},
|
|
92
|
+
async fetch(url, options) {
|
|
93
|
+
const opts = options || {};
|
|
94
|
+
fetchCalls.push({ url: String(url), options: opts });
|
|
95
|
+
const body = route ? route(String(url), opts) : null;
|
|
96
|
+
// Default: vault reports itself locked, so boot() lands on the lock
|
|
97
|
+
// screen and issues no follow-up requests. Keeps the realm deterministic.
|
|
98
|
+
const json = body !== null && body !== undefined ? body : { locked: true, hard_locked: false };
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 200,
|
|
102
|
+
json: async () => json,
|
|
103
|
+
text: async () => JSON.stringify(json),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
sandbox.location = { href: '', reload() {} };
|
|
108
|
+
sandbox.open = () => {};
|
|
109
|
+
|
|
110
|
+
vm.createContext(sandbox);
|
|
111
|
+
sandbox.window = sandbox;
|
|
112
|
+
sandbox.globalThis = sandbox;
|
|
113
|
+
vm.runInContext(script, sandbox, { filename: 'dashboard.js' });
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ctx: sandbox,
|
|
117
|
+
elements,
|
|
118
|
+
fetchCalls,
|
|
119
|
+
confirmCalls,
|
|
120
|
+
el: (id) => sandbox.document.getElementById(id),
|
|
121
|
+
/** Requests that actually carried write authority — i.e. real writes. */
|
|
122
|
+
writeCalls: () =>
|
|
123
|
+
fetchCalls.filter((c) => c.options.headers && c.options.headers['X-Clauth-Write-Token']),
|
|
124
|
+
tokenAcquisitions: () => fetchCalls.filter((c) => c.url.endsWith('/write-token')),
|
|
125
|
+
authCalls: () => fetchCalls.filter((c) => c.url.endsWith('/auth')),
|
|
126
|
+
settle: () => new Promise((r) => setImmediate(r)),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SURFACE_ID = 'factory-test-plugin:primary';
|
|
131
|
+
const ACTIONS_PATH = '/v1/surfaces/' + encodeURIComponent(SURFACE_ID) + '/actions';
|
|
132
|
+
|
|
133
|
+
/** Vault unlocked: /write-token always hands back a current token. */
|
|
134
|
+
function unlockedRoute(url, options) {
|
|
135
|
+
if (url.endsWith('/write-token')) return { ok: true, write_token: 'wt-fresh' };
|
|
136
|
+
if (url.includes('/v1/surfaces/') && url.includes('/actions')) {
|
|
137
|
+
return { operationId: 'op-42', resulting_state: { state: 'started', ok: true } };
|
|
138
|
+
}
|
|
139
|
+
if (url.endsWith('/add-service')) return { ok: true };
|
|
140
|
+
if (url.endsWith('/ping')) return { locked: true, hard_locked: false };
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Vault genuinely locked: /write-token refuses, /auth can still unlock it. */
|
|
145
|
+
function lockedRoute(url, options) {
|
|
146
|
+
if (url.endsWith('/write-token')) return { error: 'vault_locked', locked: true };
|
|
147
|
+
if (url.endsWith('/auth')) return { write_token: 'wt-after-password' };
|
|
148
|
+
if (url.includes('/v1/surfaces/') && url.includes('/actions')) {
|
|
149
|
+
return { operationId: 'op-43', resulting_state: { state: 'started', ok: true } };
|
|
150
|
+
}
|
|
151
|
+
if (url.endsWith('/add-service')) return { ok: true };
|
|
152
|
+
if (url.endsWith('/ping')) return { locked: true, hard_locked: false };
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── 1. The requirement: a button on an unlocked vault just works ─────────────
|
|
157
|
+
|
|
158
|
+
test('unlocked vault, page holds no token: the action proceeds with NO password prompt', async () => {
|
|
159
|
+
// This is the --pw / boot.key auto-unlock case — the page never saw the lock
|
|
160
|
+
// screen, so dashboardHtml() injected no token. Dave's reported flow.
|
|
161
|
+
const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
|
|
162
|
+
await h.settle();
|
|
163
|
+
|
|
164
|
+
await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
|
|
165
|
+
await h.settle();
|
|
166
|
+
|
|
167
|
+
assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', 'no password modal may open');
|
|
168
|
+
assert.equal(h.ctx.hasPendingWriteAction(), false, 'nothing may be parked');
|
|
169
|
+
assert.deepEqual(h.authCalls(), [], 'no password round-trip may occur');
|
|
170
|
+
|
|
171
|
+
const writes = h.writeCalls();
|
|
172
|
+
assert.equal(writes.length, 1, 'the action must fire, once');
|
|
173
|
+
assert.ok(writes[0].url.endsWith(ACTIONS_PATH), 'got ' + writes[0].url);
|
|
174
|
+
assert.equal(writes[0].options.headers['X-Clauth-Write-Token'], 'wt-fresh');
|
|
175
|
+
assert.deepEqual(JSON.parse(writes[0].options.body), { action: 'restart' });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('unlocked vault, page holds a STALE token: the write uses a freshly acquired one', async () => {
|
|
179
|
+
// The literal defect: the server write session has a 10-minute TTL while a
|
|
180
|
+
// dashboard tab stays open for hours, so the page kept sending a token the
|
|
181
|
+
// daemon had already expired and writeGuard answered 403 "write token
|
|
182
|
+
// required". Acquiring per action makes that unreachable.
|
|
183
|
+
const h = bootDashboard({ initWriteToken: 'wt-expired-hours-ago', route: unlockedRoute });
|
|
184
|
+
await h.settle();
|
|
185
|
+
|
|
186
|
+
await h.ctx.runSupervisorSurface(SURFACE_ID, 'stop');
|
|
187
|
+
await h.settle();
|
|
188
|
+
|
|
189
|
+
const writes = h.writeCalls();
|
|
190
|
+
assert.equal(writes.length, 1);
|
|
191
|
+
assert.equal(
|
|
192
|
+
writes[0].options.headers['X-Clauth-Write-Token'],
|
|
193
|
+
'wt-fresh',
|
|
194
|
+
'must send the refreshed token, never the stale page-load one',
|
|
195
|
+
);
|
|
196
|
+
assert.equal(h.tokenAcquisitions().length, 1, 'exactly one token acquisition per action');
|
|
197
|
+
assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('Add Service on an unlocked vault creates the service with no prompt', async () => {
|
|
201
|
+
const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
|
|
202
|
+
await h.settle();
|
|
203
|
+
|
|
204
|
+
h.el('add-name').value = 'my-new-service';
|
|
205
|
+
h.el('add-label').value = 'My New Service';
|
|
206
|
+
h.el('add-description').value = '';
|
|
207
|
+
h.el('add-project').value = '';
|
|
208
|
+
h.el('add-type').value = 'token';
|
|
209
|
+
|
|
210
|
+
await h.ctx.addService();
|
|
211
|
+
await h.settle();
|
|
212
|
+
|
|
213
|
+
assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', 'no modal for Add Service');
|
|
214
|
+
assert.deepEqual(h.authCalls(), [], 'no password round-trip');
|
|
215
|
+
|
|
216
|
+
const writes = h.writeCalls();
|
|
217
|
+
assert.equal(writes.length, 1, 'Add Service must fire exactly once');
|
|
218
|
+
assert.ok(writes[0].url.endsWith('/add-service'), 'got ' + writes[0].url);
|
|
219
|
+
assert.equal(writes[0].options.method, 'POST');
|
|
220
|
+
assert.equal(writes[0].options.headers['X-Clauth-Write-Token'], 'wt-fresh');
|
|
221
|
+
assert.deepEqual(JSON.parse(writes[0].options.body), {
|
|
222
|
+
name: 'my-new-service',
|
|
223
|
+
key_type: 'token',
|
|
224
|
+
label: 'My New Service',
|
|
225
|
+
});
|
|
226
|
+
assert.match(h.el('add-msg').textContent, /my-new-service created/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('every write action reaches the acquire path, not just the two under test', async () => {
|
|
230
|
+
// Spot-check the breadth of the choke point: each of these is a distinct
|
|
231
|
+
// call site that previously threw its own opaque string.
|
|
232
|
+
for (const [name, args] of [
|
|
233
|
+
['rescanSupervisorPlugins', []],
|
|
234
|
+
['deleteService', ['some-service']],
|
|
235
|
+
['toggleService', ['some-service']],
|
|
236
|
+
['submitMount', []],
|
|
237
|
+
['deleteMount', ['some-mount']],
|
|
238
|
+
]) {
|
|
239
|
+
const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
|
|
240
|
+
await h.settle();
|
|
241
|
+
// submitMount reads two inputs and returns early when either is blank.
|
|
242
|
+
h.el('mount-name').value = 'm';
|
|
243
|
+
h.el('mount-path').value = 'C:/tmp';
|
|
244
|
+
h.el('badge-some-service').classList.add('on');
|
|
245
|
+
|
|
246
|
+
await h.ctx[name](...args);
|
|
247
|
+
await h.settle();
|
|
248
|
+
|
|
249
|
+
assert.equal(h.tokenAcquisitions().length, 1, name + ' must acquire write access');
|
|
250
|
+
assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', name + ' must not prompt');
|
|
251
|
+
assert.equal(h.ctx.hasPendingWriteAction(), false, name + ' must not park');
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── 2. Locked-vault fallback — the only case that may prompt ─────────────────
|
|
256
|
+
|
|
257
|
+
test('genuinely locked vault: the action parks, prompts, and performs no write', async () => {
|
|
258
|
+
const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
|
|
259
|
+
await h.settle();
|
|
260
|
+
|
|
261
|
+
await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
|
|
262
|
+
await h.settle();
|
|
263
|
+
|
|
264
|
+
assert.equal(h.el('write-unlock-overlay').style.display, 'flex', 'locked vault must prompt');
|
|
265
|
+
assert.equal(h.ctx.hasPendingWriteAction(), true);
|
|
266
|
+
assert.equal(h.ctx.pendingWriteActionName(), 'runSupervisorSurface');
|
|
267
|
+
assert.deepEqual(h.writeCalls(), [], 'no write may be attempted against a locked vault');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('locked vault: after the password unlock the parked action fires exactly once', async () => {
|
|
271
|
+
const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
|
|
272
|
+
await h.settle();
|
|
273
|
+
|
|
274
|
+
await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
|
|
275
|
+
await h.settle();
|
|
276
|
+
assert.deepEqual(h.writeCalls(), []);
|
|
277
|
+
|
|
278
|
+
h.el('write-unlock-input').value = 'correct-horse';
|
|
279
|
+
await h.ctx.submitWriteUnlock();
|
|
280
|
+
await h.settle();
|
|
281
|
+
|
|
282
|
+
const writes = h.writeCalls();
|
|
283
|
+
assert.equal(writes.length, 1, 'exactly once — not zero, not twice');
|
|
284
|
+
assert.ok(writes[0].url.endsWith(ACTIONS_PATH), 'got ' + writes[0].url);
|
|
285
|
+
assert.deepEqual(JSON.parse(writes[0].options.body), { action: 'restart' }, 'arguments preserved');
|
|
286
|
+
assert.equal(h.ctx.hasPendingWriteAction(), false, 'park consumed');
|
|
287
|
+
assert.equal(h.el('write-unlock-overlay').style.display, 'none', 'modal closed');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('locked vault: cancelling clears the parked action, and no write fires later', async () => {
|
|
291
|
+
const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
|
|
292
|
+
await h.settle();
|
|
293
|
+
|
|
294
|
+
await h.ctx.runSupervisorSurface(SURFACE_ID, 'stop');
|
|
295
|
+
await h.settle();
|
|
296
|
+
assert.equal(h.ctx.hasPendingWriteAction(), true);
|
|
297
|
+
|
|
298
|
+
h.ctx.closeWriteUnlockModal();
|
|
299
|
+
assert.equal(h.ctx.hasPendingWriteAction(), false, 'cancel must disarm the parked write');
|
|
300
|
+
assert.deepEqual(h.writeCalls(), []);
|
|
301
|
+
|
|
302
|
+
h.ctx.unlockWrites();
|
|
303
|
+
h.el('write-unlock-input').value = 'correct-horse';
|
|
304
|
+
await h.ctx.submitWriteUnlock();
|
|
305
|
+
await h.settle();
|
|
306
|
+
|
|
307
|
+
assert.deepEqual(h.writeCalls(), [], 'a cancelled action must never fire, then or later');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── 3. Registry completeness — the guard against a per-call-site regression ──
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The reviewed write-action surface. Hand-maintained on purpose: growing it
|
|
314
|
+
* must be a decision someone made, not a number that drifts upward. A floor
|
|
315
|
+
* like `callers.size >= 15` cannot do this job — on a passing run the two
|
|
316
|
+
* set-differences below already force callers.size === registered.size, so a
|
|
317
|
+
* floor only ever catches the registry SHRINKING.
|
|
318
|
+
*/
|
|
319
|
+
const EXPECTED_WRITE_ACTIONS = [
|
|
320
|
+
'addService',
|
|
321
|
+
'changePassword',
|
|
322
|
+
'deleteMount',
|
|
323
|
+
'deleteService',
|
|
324
|
+
'enrollMachine',
|
|
325
|
+
'rescanSupervisorPlugins',
|
|
326
|
+
'rotateKey',
|
|
327
|
+
'runSupervisorSurface',
|
|
328
|
+
'saveKey',
|
|
329
|
+
'saveLabel',
|
|
330
|
+
'saveProject',
|
|
331
|
+
'setExpiry',
|
|
332
|
+
'submitMount',
|
|
333
|
+
'toggleService',
|
|
334
|
+
'wizSubmitCfToken',
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
function registeredWriteActions(source) {
|
|
338
|
+
const literal = source.match(/const WRITE_ACTIONS = \[([\s\S]*?)\];/);
|
|
339
|
+
assert.ok(literal, 'WRITE_ACTIONS registry must exist in the dashboard script');
|
|
340
|
+
return new Set([...literal[1].matchAll(/"([A-Za-z0-9_$]+)"/g)].map((m) => m[1]));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Every top-level dashboard function whose body calls writeHeaders().
|
|
345
|
+
*
|
|
346
|
+
* Recognises const/let/var function-expression and arrow bindings as well as
|
|
347
|
+
* function declarations. Without that, converting a write action to
|
|
348
|
+
* `const foo = async () => {…}` would attribute its writeHeaders() call to the
|
|
349
|
+
* PRECEDING declaration — and when that neighbour is itself registered, the
|
|
350
|
+
* call silently lands on an already-present name and this test passes on a
|
|
351
|
+
* genuinely unguarded action.
|
|
352
|
+
*
|
|
353
|
+
* Comment lines are skipped: prose mentioning writeHeaders() is not a call
|
|
354
|
+
* site, and counting it as one produced a false "bypasses the choke point"
|
|
355
|
+
* failure the first time a doc comment landed after a function declaration.
|
|
356
|
+
*/
|
|
357
|
+
function writeHeaderCallers(source) {
|
|
358
|
+
const callers = new Set();
|
|
359
|
+
let current = null;
|
|
360
|
+
for (const line of source.split(/\r?\n/)) {
|
|
361
|
+
const decl = line.match(/^(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/);
|
|
362
|
+
const bound = line.match(
|
|
363
|
+
/^(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:function\b|\(|[A-Za-z0-9_$]+\s*=>)/,
|
|
364
|
+
);
|
|
365
|
+
if (decl) current = decl[1];
|
|
366
|
+
else if (bound) current = bound[1];
|
|
367
|
+
|
|
368
|
+
const code = line.trim();
|
|
369
|
+
if (code.startsWith('//') || code.startsWith('*') || code.startsWith('/*')) continue;
|
|
370
|
+
if (line.includes('writeHeaders(') && current && current !== 'writeHeaders') {
|
|
371
|
+
callers.add(current);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return callers;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
test('WRITE_ACTIONS matches the reviewed write-action surface exactly', () => {
|
|
378
|
+
const registered = [...registeredWriteActions(serveSource)].sort();
|
|
379
|
+
assert.deepEqual(
|
|
380
|
+
registered,
|
|
381
|
+
EXPECTED_WRITE_ACTIONS,
|
|
382
|
+
'WRITE_ACTIONS changed — update EXPECTED_WRITE_ACTIONS deliberately, and confirm the new action is a function declaration',
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('WRITE_ACTIONS covers every function that calls writeHeaders()', () => {
|
|
387
|
+
const registered = registeredWriteActions(serveSource);
|
|
388
|
+
const callers = writeHeaderCallers(serveSource);
|
|
389
|
+
|
|
390
|
+
const unguarded = [...callers].filter((n) => !registered.has(n));
|
|
391
|
+
assert.deepEqual(unguarded, [], 'these write actions bypass the choke point: ' + unguarded.join(', '));
|
|
392
|
+
|
|
393
|
+
const stale = [...registered].filter((n) => !callers.has(n));
|
|
394
|
+
assert.deepEqual(stale, [], 'these registry entries no longer write: ' + stale.join(', '));
|
|
395
|
+
|
|
396
|
+
assert.equal(callers.size, registered.size);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('every registered write action is actually wrapped at install time', () => {
|
|
400
|
+
const h = bootDashboard({ initWriteToken: 'wt-preexisting', route: unlockedRoute });
|
|
401
|
+
const registered = [...registeredWriteActions(serveSource)];
|
|
402
|
+
|
|
403
|
+
const notWrapped = registered.filter((name) => h.ctx[name]?.__writeGuarded !== true);
|
|
404
|
+
assert.deepEqual(
|
|
405
|
+
notWrapped,
|
|
406
|
+
[],
|
|
407
|
+
'registered but not guarded in the live realm (a const/arrow binding cannot be guarded): ' + notWrapped.join(', '),
|
|
408
|
+
);
|
|
409
|
+
assert.equal(registered.length, EXPECTED_WRITE_ACTIONS.length);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('installWriteAccessGuards throws on a registered name that cannot be guarded', () => {
|
|
413
|
+
assert.match(serveSource, /clauth write-guard install failed/);
|
|
414
|
+
assert.doesNotMatch(
|
|
415
|
+
serveSource,
|
|
416
|
+
/if \(typeof fn !== "function" \|\| fn\.__writeGuarded\) continue;/,
|
|
417
|
+
'the old silent-skip guard must not come back',
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('the write-access choke point is a single mechanism, not per-call-site guards', () => {
|
|
422
|
+
const parkSites = [...serveSource.matchAll(/pendingWriteAction = \{/g)];
|
|
423
|
+
assert.equal(parkSites.length, 1, 'exactly one place may park a write action');
|
|
424
|
+
const acquireSites = [...serveSource.matchAll(/BASE \+ "\/write-token"/g)];
|
|
425
|
+
assert.equal(acquireSites.length, 1, 'exactly one place may acquire write access');
|
|
426
|
+
assert.match(serveSource, /function installWriteAccessGuards\(/);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ── 4. Server contract: the gate stays, the prompt goes ──────────────────────
|
|
430
|
+
|
|
431
|
+
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');
|
|
433
|
+
assert.match(
|
|
434
|
+
serveSource,
|
|
435
|
+
/reqPath === "\/write-token"\)\s*\{\s*\n\s*if \(!password\) \{/,
|
|
436
|
+
'a locked vault must be refused before any token is minted',
|
|
437
|
+
);
|
|
438
|
+
// writeGuard is what makes a page-driven write distinguishable from a blind
|
|
439
|
+
// remote POST arriving through the cloudflared tunnel as loopback. It stays.
|
|
440
|
+
assert.match(serveSource, /function writeGuard\(req, res\) \{[\s\S]*?validateWriteToken\(req, writeSession\)/);
|
|
441
|
+
assert.match(serveSource, /if \(!isLocal\) \{[\s\S]*?strike\(res, 403/, 'non-loopback must stay hard-rejected');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ── Pre-existing coverage — lifecycle actions and receipt feedback ───────────
|
|
445
|
+
|
|
10
446
|
test('clauth supervisor UI exposes every lifecycle action and receipt feedback', () => {
|
|
11
447
|
for (const action of ['start', 'stop', 'restart', 'reconcile', 'test', 'promote', 'rollback']) {
|
|
12
448
|
assert.match(serveSource, new RegExp('"' + action + '"'));
|
package/install.ps1
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
# clauth installer - Windows
|
|
2
|
-
# Private repo - run locally:
|
|
3
|
-
# cd C:\Dev\clauth && git pull && .\install.ps1
|
|
4
|
-
|
|
5
|
-
$ErrorActionPreference = "Stop"
|
|
6
|
-
$REPO = "https://github.com/LIFEAI/clauth.git"
|
|
7
|
-
$DIR = "$env:USERPROFILE\.clauth"
|
|
8
|
-
|
|
9
|
-
# Check git
|
|
10
|
-
try { git --version | Out-Null } catch {
|
|
11
|
-
Write-Host ""
|
|
12
|
-
Write-Host " x git is required." -ForegroundColor Red
|
|
13
|
-
Write-Host " Install from https://git-scm.com then re-run this script."
|
|
14
|
-
Write-Host ""
|
|
15
|
-
exit 1
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
# Check Node
|
|
19
|
-
try { node --version | Out-Null } catch {
|
|
20
|
-
Write-Host ""
|
|
21
|
-
Write-Host " x Node.js v18+ is required." -ForegroundColor Red
|
|
22
|
-
Write-Host " Install from https://nodejs.org then re-run this script."
|
|
23
|
-
Write-Host ""
|
|
24
|
-
exit 1
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
# Check cloudflared (soft warning - not required for install)
|
|
28
|
-
if (-not (Get-Command cloudflared -ErrorAction SilentlyContinue)) {
|
|
29
|
-
Write-Host " Note: cloudflared not found. Install after setup for claude.ai web integration." -ForegroundColor Yellow
|
|
30
|
-
Write-Host " winget install Cloudflare.cloudflared" -ForegroundColor Gray
|
|
31
|
-
Write-Host ""
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
# Skip bootstrap if CLAUTH npm package is already installed globally
|
|
35
|
-
$alreadyInstalled = $false
|
|
36
|
-
try {
|
|
37
|
-
$ver = & npm list -g @lifeaitools/clauth --depth=0 2>$null | Select-String "@lifeaitools/clauth"
|
|
38
|
-
if ($ver) { $alreadyInstalled = $true }
|
|
39
|
-
} catch { }
|
|
40
|
-
|
|
41
|
-
if ($alreadyInstalled) {
|
|
42
|
-
Write-Host " clauth already installed globally - skipping bootstrap." -ForegroundColor Green
|
|
43
|
-
} else {
|
|
44
|
-
# Clone or update
|
|
45
|
-
if (Test-Path "$DIR\.git") {
|
|
46
|
-
Write-Host " Updating clauth..."
|
|
47
|
-
Set-Location $DIR; git pull --quiet
|
|
48
|
-
} else {
|
|
49
|
-
Write-Host " Cloning clauth..."
|
|
50
|
-
git clone --quiet $REPO $DIR
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
# Run compiled bootstrap binary
|
|
54
|
-
$bootstrap = "$DIR\scripts\bin\bootstrap-win.exe"
|
|
55
|
-
if (-not (Test-Path $bootstrap)) {
|
|
56
|
-
Write-Host " x Bootstrap binary not found at $bootstrap" -ForegroundColor Red
|
|
57
|
-
exit 1
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
Set-Location $DIR
|
|
61
|
-
& $bootstrap
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
# Register autostart - idempotent, runs every install/update
|
|
65
|
-
# Primary: Task Scheduler (preferred - supports delay and OS-managed restarts)
|
|
66
|
-
# Fallback: Startup folder shortcut (fully functional - autostart.ps1 has its own crash-recovery loop)
|
|
67
|
-
$autostartScript = "$env:APPDATA\clauth\autostart.ps1"
|
|
68
|
-
$taskName = "CLAUTH Daemon"
|
|
69
|
-
|
|
70
|
-
if (Test-Path $autostartScript) {
|
|
71
|
-
Write-Host ""
|
|
72
|
-
Write-Host " Registering autostart..." -ForegroundColor Cyan
|
|
73
|
-
|
|
74
|
-
$registered = $false
|
|
75
|
-
|
|
76
|
-
# Try Task Scheduler first
|
|
77
|
-
try {
|
|
78
|
-
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
79
|
-
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
|
|
80
|
-
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$autostartScript`""
|
|
81
|
-
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
|
82
|
-
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
|
83
|
-
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
|
|
84
|
-
-Settings $settings -RunLevel Limited -Force -ErrorAction Stop | Out-Null
|
|
85
|
-
Write-Host " + Task Scheduler: '$taskName' registered (triggers at logon)." -ForegroundColor Green
|
|
86
|
-
$registered = $true
|
|
87
|
-
} catch { }
|
|
88
|
-
|
|
89
|
-
# Fallback: Startup folder - valid solution, autostart.ps1 handles crash recovery internally
|
|
90
|
-
if (-not $registered) {
|
|
91
|
-
$startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
|
|
92
|
-
$shortcut = "$startupDir\clauth-autostart.lnk"
|
|
93
|
-
$wsh = New-Object -ComObject WScript.Shell
|
|
94
|
-
$lnk = $wsh.CreateShortcut($shortcut)
|
|
95
|
-
$lnk.TargetPath = "powershell.exe"
|
|
96
|
-
# 15s delay baked in so profile is loaded before daemon starts
|
|
97
|
-
$lnk.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command `"Start-Sleep 15; & '$autostartScript'`""
|
|
98
|
-
$lnk.WorkingDirectory = "$env:APPDATA\clauth"
|
|
99
|
-
$lnk.WindowStyle = 7
|
|
100
|
-
$lnk.Save()
|
|
101
|
-
Write-Host " + Autostart registered via Startup folder (15s delay, crash-recovery loop active)." -ForegroundColor Green
|
|
102
|
-
}
|
|
1
|
+
# clauth installer - Windows
|
|
2
|
+
# Private repo - run locally:
|
|
3
|
+
# cd C:\Dev\clauth && git pull && .\install.ps1
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Stop"
|
|
6
|
+
$REPO = "https://github.com/LIFEAI/clauth.git"
|
|
7
|
+
$DIR = "$env:USERPROFILE\.clauth"
|
|
8
|
+
|
|
9
|
+
# Check git
|
|
10
|
+
try { git --version | Out-Null } catch {
|
|
11
|
+
Write-Host ""
|
|
12
|
+
Write-Host " x git is required." -ForegroundColor Red
|
|
13
|
+
Write-Host " Install from https://git-scm.com then re-run this script."
|
|
14
|
+
Write-Host ""
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Check Node
|
|
19
|
+
try { node --version | Out-Null } catch {
|
|
20
|
+
Write-Host ""
|
|
21
|
+
Write-Host " x Node.js v18+ is required." -ForegroundColor Red
|
|
22
|
+
Write-Host " Install from https://nodejs.org then re-run this script."
|
|
23
|
+
Write-Host ""
|
|
24
|
+
exit 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Check cloudflared (soft warning - not required for install)
|
|
28
|
+
if (-not (Get-Command cloudflared -ErrorAction SilentlyContinue)) {
|
|
29
|
+
Write-Host " Note: cloudflared not found. Install after setup for claude.ai web integration." -ForegroundColor Yellow
|
|
30
|
+
Write-Host " winget install Cloudflare.cloudflared" -ForegroundColor Gray
|
|
31
|
+
Write-Host ""
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Skip bootstrap if CLAUTH npm package is already installed globally
|
|
35
|
+
$alreadyInstalled = $false
|
|
36
|
+
try {
|
|
37
|
+
$ver = & npm list -g @lifeaitools/clauth --depth=0 2>$null | Select-String "@lifeaitools/clauth"
|
|
38
|
+
if ($ver) { $alreadyInstalled = $true }
|
|
39
|
+
} catch { }
|
|
40
|
+
|
|
41
|
+
if ($alreadyInstalled) {
|
|
42
|
+
Write-Host " clauth already installed globally - skipping bootstrap." -ForegroundColor Green
|
|
43
|
+
} else {
|
|
44
|
+
# Clone or update
|
|
45
|
+
if (Test-Path "$DIR\.git") {
|
|
46
|
+
Write-Host " Updating clauth..."
|
|
47
|
+
Set-Location $DIR; git pull --quiet
|
|
48
|
+
} else {
|
|
49
|
+
Write-Host " Cloning clauth..."
|
|
50
|
+
git clone --quiet $REPO $DIR
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Run compiled bootstrap binary
|
|
54
|
+
$bootstrap = "$DIR\scripts\bin\bootstrap-win.exe"
|
|
55
|
+
if (-not (Test-Path $bootstrap)) {
|
|
56
|
+
Write-Host " x Bootstrap binary not found at $bootstrap" -ForegroundColor Red
|
|
57
|
+
exit 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Set-Location $DIR
|
|
61
|
+
& $bootstrap
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Register autostart - idempotent, runs every install/update
|
|
65
|
+
# Primary: Task Scheduler (preferred - supports delay and OS-managed restarts)
|
|
66
|
+
# Fallback: Startup folder shortcut (fully functional - autostart.ps1 has its own crash-recovery loop)
|
|
67
|
+
$autostartScript = "$env:APPDATA\clauth\autostart.ps1"
|
|
68
|
+
$taskName = "CLAUTH Daemon"
|
|
69
|
+
|
|
70
|
+
if (Test-Path $autostartScript) {
|
|
71
|
+
Write-Host ""
|
|
72
|
+
Write-Host " Registering autostart..." -ForegroundColor Cyan
|
|
73
|
+
|
|
74
|
+
$registered = $false
|
|
75
|
+
|
|
76
|
+
# Try Task Scheduler first
|
|
77
|
+
try {
|
|
78
|
+
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
79
|
+
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
|
|
80
|
+
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$autostartScript`""
|
|
81
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
|
82
|
+
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
|
83
|
+
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
|
|
84
|
+
-Settings $settings -RunLevel Limited -Force -ErrorAction Stop | Out-Null
|
|
85
|
+
Write-Host " + Task Scheduler: '$taskName' registered (triggers at logon)." -ForegroundColor Green
|
|
86
|
+
$registered = $true
|
|
87
|
+
} catch { }
|
|
88
|
+
|
|
89
|
+
# Fallback: Startup folder - valid solution, autostart.ps1 handles crash recovery internally
|
|
90
|
+
if (-not $registered) {
|
|
91
|
+
$startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
|
|
92
|
+
$shortcut = "$startupDir\clauth-autostart.lnk"
|
|
93
|
+
$wsh = New-Object -ComObject WScript.Shell
|
|
94
|
+
$lnk = $wsh.CreateShortcut($shortcut)
|
|
95
|
+
$lnk.TargetPath = "powershell.exe"
|
|
96
|
+
# 15s delay baked in so profile is loaded before daemon starts
|
|
97
|
+
$lnk.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command `"Start-Sleep 15; & '$autostartScript'`""
|
|
98
|
+
$lnk.WorkingDirectory = "$env:APPDATA\clauth"
|
|
99
|
+
$lnk.WindowStyle = 7
|
|
100
|
+
$lnk.Save()
|
|
101
|
+
Write-Host " + Autostart registered via Startup folder (15s delay, crash-recovery loop active)." -ForegroundColor Green
|
|
102
|
+
}
|
|
103
103
|
} else {
|
|
104
104
|
Write-Host ""
|
|
105
105
|
Write-Host " ! autostart.ps1 not found - skipping autostart registration." -ForegroundColor Yellow
|