@lifeaitools/clauth 1.31.1 → 2.0.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/SKILL.md +31 -0
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/.clauth-skill/references/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/api.js +238 -238
- package/cli/commands/install.js +396 -396
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1381 -1644
- package/cli/commands/uninstall.js +164 -164
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +165 -1
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/supervisor-registry.js +403 -6
- package/cli/supervisor-registry.test.js +496 -4
- package/cli/supervisor-ui.test.js +436 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +4 -3
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- 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/cli/watchdog-registry.js
CHANGED
|
@@ -178,7 +178,30 @@ export function readWatchdogEvents(limit = 100) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
async function verifyRestartHealth(service) {
|
|
182
|
+
if (!service.health?.url) return { ok: true, health_status: "not_configured" };
|
|
183
|
+
|
|
184
|
+
const attempts = Number(service.health.readyAttempts || 20);
|
|
185
|
+
const delayMs = Number(service.health.readyDelayMs || 250);
|
|
186
|
+
let observed;
|
|
187
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
188
|
+
observed = await evaluateWatchdogService(service);
|
|
189
|
+
if (observed.status === "healthy") {
|
|
190
|
+
return { ok: true, health_status: observed.status, health_http_status: observed.httpStatus, attempts: attempt };
|
|
191
|
+
}
|
|
192
|
+
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: "restart_health_unreachable",
|
|
197
|
+
health_status: observed?.status || "unknown",
|
|
198
|
+
health_http_status: observed?.httpStatus,
|
|
199
|
+
health_error: observed?.error,
|
|
200
|
+
attempts,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function restartWatchdogService(id) {
|
|
182
205
|
const service = loadRegistry().services.find((candidate) => candidate.id === id);
|
|
183
206
|
if (!service) return { ok: false, error: "service_not_registered" };
|
|
184
207
|
if (!service.restart) return { ok: false, error: "restart_not_configured" };
|
|
@@ -192,18 +215,23 @@ export function restartWatchdogService(id) {
|
|
|
192
215
|
encoding: "utf8",
|
|
193
216
|
timeout: Number(service.restart.timeoutMs || 30000),
|
|
194
217
|
});
|
|
218
|
+
const health = result.status === 0 ? await verifyRestartHealth(service) : { ok: false, health_status: "not_checked" };
|
|
195
219
|
const event = {
|
|
196
220
|
kind: "restart",
|
|
197
221
|
service_id: id,
|
|
198
222
|
status: result.status,
|
|
223
|
+
health_status: health.health_status,
|
|
224
|
+
health_http_status: health.health_http_status,
|
|
225
|
+
health_error: health.health_error,
|
|
199
226
|
error: result.error ? result.error.message : undefined,
|
|
200
227
|
};
|
|
201
228
|
appendEvent(event);
|
|
202
229
|
return {
|
|
203
|
-
ok: result.status === 0,
|
|
230
|
+
ok: result.status === 0 && health.ok,
|
|
204
231
|
status: result.status,
|
|
205
232
|
stdout: result.stdout,
|
|
206
233
|
stderr: result.stderr,
|
|
207
234
|
error: result.error ? result.error.message : undefined,
|
|
235
|
+
...health,
|
|
208
236
|
};
|
|
209
237
|
}
|
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
validateWatchdogService,
|
|
14
14
|
} from "./watchdog-registry.js";
|
|
15
15
|
|
|
16
|
-
function withTempRegistry(fn) {
|
|
16
|
+
async function withTempRegistry(fn) {
|
|
17
17
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-watchdog-"));
|
|
18
18
|
const old = process.env.CLAUTH_WATCHDOG_DIR;
|
|
19
19
|
process.env.CLAUTH_WATCHDOG_DIR = dir;
|
|
20
20
|
try {
|
|
21
|
-
return fn(dir);
|
|
21
|
+
return await fn(dir);
|
|
22
22
|
} finally {
|
|
23
23
|
if (old === undefined) delete process.env.CLAUTH_WATCHDOG_DIR;
|
|
24
24
|
else process.env.CLAUTH_WATCHDOG_DIR = old;
|
|
@@ -78,12 +78,35 @@ test("registerWatchdogManifest upserts services by id", () => withTempRegistry((
|
|
|
78
78
|
assert.equal(registry.services.find((service) => service.id === "codeflow").label, "CodeFlow Updated");
|
|
79
79
|
}));
|
|
80
80
|
|
|
81
|
-
test("restartWatchdogService rejects missing and unapproved services", () => withTempRegistry(() => {
|
|
82
|
-
assert.deepEqual(restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
|
|
81
|
+
test("restartWatchdogService rejects missing and unapproved services", async () => withTempRegistry(async () => {
|
|
82
|
+
assert.deepEqual(await restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
|
|
83
83
|
registerWatchdogManifest({
|
|
84
84
|
services: [
|
|
85
85
|
{ id: "dev-center", label: "Dev Center", kind: "process", restart: { cmd: "node", args: ["--version"] } },
|
|
86
86
|
],
|
|
87
87
|
});
|
|
88
|
-
assert.deepEqual(restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
|
|
88
|
+
assert.deepEqual(await restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
test("restartWatchdogService requires registered health after launching", async () => withTempRegistry(async () => {
|
|
92
|
+
registerWatchdogManifest({
|
|
93
|
+
services: [{
|
|
94
|
+
id: "health-gated",
|
|
95
|
+
label: "Health gated",
|
|
96
|
+
kind: "http",
|
|
97
|
+
health: { url: "http://127.0.0.1:3109/health", readyAttempts: 2, readyDelayMs: 0 },
|
|
98
|
+
restart: { cmd: process.execPath, args: ["--version"] },
|
|
99
|
+
approvalRequired: false,
|
|
100
|
+
}],
|
|
101
|
+
});
|
|
102
|
+
const originalFetch = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = async () => ({ ok: false, status: 503 });
|
|
104
|
+
try {
|
|
105
|
+
const result = await restartWatchdogService("health-gated");
|
|
106
|
+
assert.equal(result.ok, false);
|
|
107
|
+
assert.equal(result.error, "restart_health_unreachable");
|
|
108
|
+
assert.equal(result.health_status, "degraded");
|
|
109
|
+
} finally {
|
|
110
|
+
globalThis.fetch = originalFetch;
|
|
111
|
+
}
|
|
89
112
|
}));
|