@celilo/cli 2.1.0 → 2.2.0
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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
package/src/hooks/broker.test.ts
CHANGED
|
@@ -18,7 +18,9 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { MissingProviderInputError } from '@celilo/capabilities';
|
|
19
19
|
import { capabilityShape } from './broker';
|
|
20
20
|
import { executeHookScript } from './executor';
|
|
21
|
+
import type { HookStoreBackend, HookStores } from './hook-store';
|
|
21
22
|
import { createCapturingLogger } from './logger';
|
|
23
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
22
24
|
import type { HookContext } from './types';
|
|
23
25
|
|
|
24
26
|
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
@@ -49,8 +51,8 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
49
51
|
const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-'));
|
|
50
52
|
try {
|
|
51
53
|
const context: HookContext = {
|
|
52
|
-
config:
|
|
53
|
-
secrets:
|
|
54
|
+
config: configStore(),
|
|
55
|
+
secrets: secretStore(),
|
|
54
56
|
systems: [],
|
|
55
57
|
logger: createCapturingLogger().logger,
|
|
56
58
|
debug: false,
|
|
@@ -67,6 +69,84 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* In-memory stand-ins for the broker-side stores. The storage mechanics have
|
|
74
|
+
* their own suite (`hook-store.test.ts`, real DB); what this layer must prove
|
|
75
|
+
* is the ROUND TRIP — a write in the hook's process landing in the broker's
|
|
76
|
+
* store and the read coming back.
|
|
77
|
+
*/
|
|
78
|
+
function memoryStores(): HookStores {
|
|
79
|
+
const makeBackend = (
|
|
80
|
+
kind: 'secret' | 'hook-owned config',
|
|
81
|
+
declared: string[],
|
|
82
|
+
): HookStoreBackend => {
|
|
83
|
+
const rows = new Map<string, string>();
|
|
84
|
+
const assertDeclared = (name: string) => {
|
|
85
|
+
if (!declared.includes(name)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Module 'test-module' has no declared ${kind} '${name}'. Declared ${kind} names: ${declared.join(', ')}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
get: (name) => {
|
|
93
|
+
assertDeclared(name);
|
|
94
|
+
return Promise.resolve(rows.get(name));
|
|
95
|
+
},
|
|
96
|
+
set: (name, value) => {
|
|
97
|
+
assertDeclared(name);
|
|
98
|
+
rows.set(name, value);
|
|
99
|
+
return Promise.resolve();
|
|
100
|
+
},
|
|
101
|
+
delete: (name) => {
|
|
102
|
+
assertDeclared(name);
|
|
103
|
+
rows.delete(name);
|
|
104
|
+
return Promise.resolve();
|
|
105
|
+
},
|
|
106
|
+
applyTransaction: (ops) => {
|
|
107
|
+
for (const entry of ops) {
|
|
108
|
+
assertDeclared(entry.name);
|
|
109
|
+
if (entry.op === 'set') rows.set(entry.name, entry.value ?? '');
|
|
110
|
+
else rows.delete(entry.name);
|
|
111
|
+
}
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
secrets: makeBackend('secret', ['bot_token', 'api_key']),
|
|
118
|
+
config: makeBackend('hook-owned config', ['public_ip']),
|
|
119
|
+
declaredSecretNames: ['bot_token', 'api_key'],
|
|
120
|
+
declaredConfigNames: ['public_ip'],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runStoreHook(
|
|
125
|
+
contextData: Record<string, unknown>,
|
|
126
|
+
): Promise<Record<string, unknown>> {
|
|
127
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-store-'));
|
|
128
|
+
try {
|
|
129
|
+
const context = {
|
|
130
|
+
config: { mapOnlyValue: 'from-the-context-frame' },
|
|
131
|
+
secrets: {},
|
|
132
|
+
systems: [],
|
|
133
|
+
logger: createCapturingLogger().logger,
|
|
134
|
+
debug: false,
|
|
135
|
+
screenshotDir: dir,
|
|
136
|
+
stateDir: dir,
|
|
137
|
+
capabilities: {},
|
|
138
|
+
...contextData,
|
|
139
|
+
} as unknown as HookContext;
|
|
140
|
+
return await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
|
|
141
|
+
timeoutMs: 30_000,
|
|
142
|
+
idleTimeoutMs: 30_000,
|
|
143
|
+
hookStores: () => Promise.resolve(memoryStores()),
|
|
144
|
+
});
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
70
150
|
describe('capabilityShape', () => {
|
|
71
151
|
test('splits functions from data', () => {
|
|
72
152
|
const shape = capabilityShape(demoCapabilities());
|
|
@@ -149,3 +229,27 @@ describe('capability calls across the boundary', () => {
|
|
|
149
229
|
expect(orphans).toEqual([]);
|
|
150
230
|
}, 30_000);
|
|
151
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('hook-owned-state store calls across the boundary', () => {
|
|
234
|
+
test('every store operation survives the round trip', async () => {
|
|
235
|
+
const outputs = await runStoreHook({});
|
|
236
|
+
|
|
237
|
+
expect(outputs.secretRoundTrip).toBe('token-value');
|
|
238
|
+
expect(outputs.configRoundTrip).toBe('203.0.113.7');
|
|
239
|
+
// The old map surface still answers beside the new methods.
|
|
240
|
+
expect(outputs.mapRead).toBe('from-the-context-frame');
|
|
241
|
+
expect(outputs.deletedIsGone).toBeUndefined();
|
|
242
|
+
|
|
243
|
+
// D4: plain sets land immediately; a transaction discards on throw.
|
|
244
|
+
expect(outputs.transactionA).toBe('committed-a');
|
|
245
|
+
expect(outputs.transactionB).toBe('committed-b');
|
|
246
|
+
expect(outputs.discarded).toBe('hook failed midway');
|
|
247
|
+
expect(outputs.afterDiscard).toBe('committed-a');
|
|
248
|
+
|
|
249
|
+
// D2: the undeclared name is an ERROR naming the module and the set. The
|
|
250
|
+
// message text itself is pinned by hook-store.test.ts; here it only has
|
|
251
|
+
// to have survived the wire intact.
|
|
252
|
+
expect(outputs.undeclaredSecret).toContain('not_declared');
|
|
253
|
+
expect(outputs.undeclaredSecret).toContain('bot_token');
|
|
254
|
+
}, 30_000);
|
|
255
|
+
});
|
package/src/hooks/broker.ts
CHANGED
|
@@ -23,7 +23,10 @@ import { type Server, type Socket, createServer } from 'node:net';
|
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import {
|
|
26
|
+
type BufferedStoreOp,
|
|
26
27
|
type CapabilityShape,
|
|
28
|
+
type ChildFrame,
|
|
29
|
+
HOOK_PROTOCOL_VERSION,
|
|
27
30
|
type HookError,
|
|
28
31
|
createLineReader,
|
|
29
32
|
encodeFrame,
|
|
@@ -31,8 +34,22 @@ import {
|
|
|
31
34
|
serializeError,
|
|
32
35
|
versionMismatch,
|
|
33
36
|
} from './hook-protocol';
|
|
37
|
+
import type { HookStores } from './hook-store';
|
|
34
38
|
import type { HookLogger } from './types';
|
|
35
39
|
|
|
40
|
+
/** The answering half's view of one store frame. */
|
|
41
|
+
type StoreCallFrame = Extract<ChildFrame, { type: 'store' }>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A lazy provider of the module's two hook state stores.
|
|
45
|
+
*
|
|
46
|
+
* Lazy because building the stores reads the module row and its manifest, and
|
|
47
|
+
* because the secrets store touches the master key only when an operation
|
|
48
|
+
* actually needs it. A hook that never calls `context.secrets` should pay
|
|
49
|
+
* none of that.
|
|
50
|
+
*/
|
|
51
|
+
export type HookStoresProvider = () => Promise<HookStores>;
|
|
52
|
+
|
|
36
53
|
/**
|
|
37
54
|
* How long to wait after the child exits for its last frame to arrive. Short,
|
|
38
55
|
* because by this point the writer is already gone and the bytes are either in
|
|
@@ -56,6 +73,13 @@ export interface BrokerOptions {
|
|
|
56
73
|
logger: HookLogger;
|
|
57
74
|
/** Called on every frame, so the idle timer measures the whole channel. */
|
|
58
75
|
onActivity: () => void;
|
|
76
|
+
/**
|
|
77
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5). Absent
|
|
78
|
+
* means this run has none wired, and a `store` frame from the hook is
|
|
79
|
+
* REFUSED with an error naming the gap — a write that vanishes silently is
|
|
80
|
+
* the exact defect the accessor exists to remove.
|
|
81
|
+
*/
|
|
82
|
+
stores?: HookStoresProvider;
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
export interface Broker {
|
|
@@ -163,6 +187,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
163
187
|
let connection: Socket | undefined;
|
|
164
188
|
// Already resolved: no connection means nothing left to deliver.
|
|
165
189
|
let connectionClosed: Promise<void> = Promise.resolve();
|
|
190
|
+
// The module's stores, resolved from the provider on first use.
|
|
191
|
+
let storesCache: HookStores | undefined;
|
|
166
192
|
|
|
167
193
|
const server: Server = createServer((socket) => {
|
|
168
194
|
if (connection) {
|
|
@@ -200,7 +226,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
200
226
|
}
|
|
201
227
|
send({
|
|
202
228
|
type: 'context',
|
|
203
|
-
protocolVersion:
|
|
229
|
+
protocolVersion: HOOK_PROTOCOL_VERSION,
|
|
204
230
|
scriptPath: options.scriptPath,
|
|
205
231
|
context: options.context,
|
|
206
232
|
});
|
|
@@ -216,6 +242,10 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
216
242
|
void dispatch(frame.id, frame.capability, frame.method, frame.args, send);
|
|
217
243
|
return;
|
|
218
244
|
|
|
245
|
+
case 'store':
|
|
246
|
+
void dispatchStore(frame, send);
|
|
247
|
+
return;
|
|
248
|
+
|
|
219
249
|
case 'result':
|
|
220
250
|
outcome = { ok: true, outputs: frame.outputs };
|
|
221
251
|
return;
|
|
@@ -285,6 +315,66 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
285
315
|
server.listen(socketPath, resolve);
|
|
286
316
|
});
|
|
287
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Answer one hook-owned-state store call.
|
|
320
|
+
*
|
|
321
|
+
* The stores resolve on first use and stay cached for the run. Everything
|
|
322
|
+
* that can fail — an undeclared name, a missing module row, a store that was
|
|
323
|
+
* never wired — answers as a `throw` frame rather than crashing the broker,
|
|
324
|
+
* so the hook's own `try`/`catch` sees it the way an in-process throw would
|
|
325
|
+
* behave.
|
|
326
|
+
*/
|
|
327
|
+
async function dispatchStore(
|
|
328
|
+
frame: StoreCallFrame,
|
|
329
|
+
send: (frame: Parameters<typeof encodeFrame>[0]) => void,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
if (stopped) {
|
|
332
|
+
send({
|
|
333
|
+
type: 'throw',
|
|
334
|
+
id: frame.id,
|
|
335
|
+
error: {
|
|
336
|
+
name: 'Error',
|
|
337
|
+
message: `Hook run has ended; refusing context.${frame.store}.${frame.method}.`,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (!options.stores) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`No hook state stores were wired for this run, so context.${frame.store} is unavailable. This is a celilo bug: every hook invocation must receive both stores (hook-owned-state task 3.5).`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
storesCache ??= await options.stores();
|
|
350
|
+
const backend = storesCache[frame.store];
|
|
351
|
+
|
|
352
|
+
let value: unknown;
|
|
353
|
+
switch (frame.method) {
|
|
354
|
+
case 'get':
|
|
355
|
+
value = await backend.get(frame.args[0] as string);
|
|
356
|
+
break;
|
|
357
|
+
case 'set':
|
|
358
|
+
await backend.set(frame.args[0] as string, frame.args[1] as string);
|
|
359
|
+
value = null;
|
|
360
|
+
break;
|
|
361
|
+
case 'delete':
|
|
362
|
+
await backend.delete(frame.args[0] as string);
|
|
363
|
+
value = null;
|
|
364
|
+
break;
|
|
365
|
+
case 'transaction':
|
|
366
|
+
await backend.applyTransaction(frame.args[0] as BufferedStoreOp[]);
|
|
367
|
+
value = null;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
options.onActivity();
|
|
371
|
+
send({ type: 'return', id: frame.id, value });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
options.onActivity();
|
|
374
|
+
send({ type: 'throw', id: frame.id, error: serializeError(error) });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
288
378
|
return {
|
|
289
379
|
socketPath,
|
|
290
380
|
drained: () =>
|
|
@@ -302,6 +302,39 @@ describe('Firewall Chain Building', () => {
|
|
|
302
302
|
expect(config.isolateTransitNetwork).toBe(true);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
test('single provider: the recorded interface_baseline is forwarded as a parsed list', async () => {
|
|
306
|
+
// The recorded baseline is what turns an undeclared interface from a
|
|
307
|
+
// refusal into an isolation. The single-provider iptables firewall (the
|
|
308
|
+
// production shape: the ISP router is not a celilo module) is built by
|
|
309
|
+
// buildCapabilityInterface, and THIS is the construction site that
|
|
310
|
+
// dropped the field — the baseline was written to module config and
|
|
311
|
+
// never read back, so the module converged in onboarding mode forever
|
|
312
|
+
// (ce-qzxm).
|
|
313
|
+
installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]');
|
|
314
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
315
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
316
|
+
upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0, eth1, eth2, eth3');
|
|
317
|
+
|
|
318
|
+
const config = await loadedConfig();
|
|
319
|
+
// A parsed array of NAMES, as the module's D5 split consumes it — not
|
|
320
|
+
// the raw comma string, and not with the write-side padding intact.
|
|
321
|
+
expect(config.interfaceBaseline).toEqual(['eth0', 'eth1', 'eth2', 'eth3']);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('single provider: an empty interface_baseline arrives undefined, meaning onboarding', async () => {
|
|
325
|
+
// Empty and absent are the same thing, and both mean "never converged
|
|
326
|
+
// cleanly": treating an empty string as a baseline of nothing would make
|
|
327
|
+
// every interface new. The loader normalizes this so the module sees
|
|
328
|
+
// one shape for one meaning.
|
|
329
|
+
installReporter('iptables', '{"has_external":true}', '["dmz"]');
|
|
330
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
331
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
332
|
+
upsertModuleConfig(db, 'iptables', 'interface_baseline', '');
|
|
333
|
+
|
|
334
|
+
const config = await loadedConfig();
|
|
335
|
+
expect(config.interfaceBaseline).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
305
338
|
test('chained provider: the downstream layer gets them too', async () => {
|
|
306
339
|
// greenwave owns the WAN, so iptables is built through buildFirewallChain
|
|
307
340
|
// rather than the single-provider path. That is a SECOND construction
|
|
@@ -324,10 +357,14 @@ describe('Firewall Chain Building', () => {
|
|
|
324
357
|
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
325
358
|
upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
|
|
326
359
|
upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
|
|
360
|
+
upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth1');
|
|
327
361
|
|
|
328
362
|
const config = await loadedConfig();
|
|
329
363
|
expect(config.defaultRouteZone).toBe('internal');
|
|
330
364
|
expect(config.isolateTransitNetwork).toBe(true);
|
|
365
|
+
// The downstream construction site already forwarded this; pinned so the
|
|
366
|
+
// two sites cannot drift apart again.
|
|
367
|
+
expect(config.interfaceBaseline).toEqual(['eth0', 'eth1']);
|
|
331
368
|
});
|
|
332
369
|
|
|
333
370
|
test('unset settings arrive undefined, not as a wrong default', async () => {
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
systemConfig,
|
|
40
40
|
webRoutes,
|
|
41
41
|
} from '../db/schema';
|
|
42
|
-
import { resolveModuleWebRoot } from '../module/web-root';
|
|
42
|
+
import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root';
|
|
43
43
|
import { decryptSecret } from '../secrets/encryption';
|
|
44
44
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
45
45
|
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
@@ -408,6 +408,10 @@ export async function loadCapabilityFunctions(
|
|
|
408
408
|
// without this they could not find the caller's web root now that
|
|
409
409
|
// `sourceDir` has left the request (D10 amendment).
|
|
410
410
|
consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
411
|
+
// WHERE THE CALLER'S GENERATED SITE FILES ARE (celilo#1265), by the
|
|
412
|
+
// same seam. A jailed hook cannot write into its own web root, so a
|
|
413
|
+
// module-provided web provider needs the state overlay to honor it.
|
|
414
|
+
consumerStateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
|
|
411
415
|
});
|
|
412
416
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
413
417
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -594,6 +598,7 @@ export async function loadCapabilityFunctions(
|
|
|
594
598
|
// Resolved here, not in the capability: on a converge there is no
|
|
595
599
|
// consumer process to ask (D10 amendment).
|
|
596
600
|
webRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
601
|
+
stateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
|
|
597
602
|
logger,
|
|
598
603
|
config: providerConfig,
|
|
599
604
|
secrets: providerSecrets,
|
|
@@ -789,6 +794,15 @@ function buildCapabilityInterface(
|
|
|
789
794
|
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
790
795
|
controlPlaneSubnet: zones?.controlPlaneSubnet,
|
|
791
796
|
frontedSubnets: zones?.frontedSubnets ?? [],
|
|
797
|
+
// The recorded baseline (D12), from this firewall's own module config.
|
|
798
|
+
// Absent means the box has never converged cleanly, so an interface
|
|
799
|
+
// celilo cannot attribute refuses rather than being disabled. The
|
|
800
|
+
// downstream-chain site below forwards this too; the single-provider
|
|
801
|
+
// firewall (an iptables box with the WAN held by a non-celilo ISP
|
|
802
|
+
// router) is built HERE, and this is the site that dropped it — the
|
|
803
|
+
// baseline was recorded but never read back, so every converge stayed
|
|
804
|
+
// in onboarding mode and refused instead of isolating (ce-qzxm).
|
|
805
|
+
interfaceBaseline: parseInterfaceBaseline(config.interface_baseline),
|
|
792
806
|
// Operator settings, forwarded verbatim. `parseStoredConfigValue`
|
|
793
807
|
// preserves each manifest-declared type, so the boolean arrives as a
|
|
794
808
|
// boolean and needs no coercion here.
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
isCompiledCapabilityFactory,
|
|
31
31
|
isCompiledHook,
|
|
32
32
|
} from '@celilo/capabilities';
|
|
33
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
33
34
|
|
|
34
35
|
function makeLogger(): HookLogger {
|
|
35
36
|
return {
|
|
@@ -42,8 +43,8 @@ function makeLogger(): HookLogger {
|
|
|
42
43
|
|
|
43
44
|
function makeContext(overrides: Partial<HookContext> = {}): HookContext {
|
|
44
45
|
return {
|
|
45
|
-
config:
|
|
46
|
-
secrets:
|
|
46
|
+
config: configStore(),
|
|
47
|
+
secrets: secretStore(),
|
|
47
48
|
systems: [],
|
|
48
49
|
logger: makeLogger(),
|
|
49
50
|
consumerModuleId: 'test-consumer',
|
|
@@ -152,7 +153,7 @@ describe('defineHook', () => {
|
|
|
152
153
|
},
|
|
153
154
|
});
|
|
154
155
|
|
|
155
|
-
await hook(makeContext({ config: { foo: 'bar' } }));
|
|
156
|
+
await hook(makeContext({ config: configStore({ foo: 'bar' }) }));
|
|
156
157
|
|
|
157
158
|
expect(seenConfig).toEqual({ foo: 'bar' });
|
|
158
159
|
});
|
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
} from './executor';
|
|
16
16
|
import { readJailMode } from './jail';
|
|
17
17
|
import { createCapturingLogger } from './logger';
|
|
18
|
-
import
|
|
18
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
19
|
+
import type { HookContext, HookDefinition } from './types';
|
|
19
20
|
|
|
20
21
|
const FIXTURES_DIR = join(__dirname, 'test-fixtures');
|
|
21
22
|
|
|
@@ -115,8 +116,8 @@ describe('Hook Executor', () => {
|
|
|
115
116
|
const scriptPath = join(FIXTURES_DIR, 'success-hook.ts');
|
|
116
117
|
|
|
117
118
|
const result = await executeHookScript(scriptPath, {
|
|
118
|
-
config: { username: 'testuser' },
|
|
119
|
-
secrets: { password: 'secret' },
|
|
119
|
+
config: configStore({ username: 'testuser' }),
|
|
120
|
+
secrets: secretStore({ password: 'secret' }),
|
|
120
121
|
systems: [],
|
|
121
122
|
logger,
|
|
122
123
|
debug: false,
|
|
@@ -136,8 +137,8 @@ describe('Hook Executor', () => {
|
|
|
136
137
|
|
|
137
138
|
await expect(
|
|
138
139
|
executeHookScript('/nonexistent/hook.ts', {
|
|
139
|
-
config:
|
|
140
|
-
secrets:
|
|
140
|
+
config: configStore(),
|
|
141
|
+
secrets: secretStore(),
|
|
141
142
|
systems: [],
|
|
142
143
|
logger,
|
|
143
144
|
debug: false,
|
|
@@ -154,8 +155,8 @@ describe('Hook Executor', () => {
|
|
|
154
155
|
|
|
155
156
|
await expect(
|
|
156
157
|
executeHookScript(scriptPath, {
|
|
157
|
-
config:
|
|
158
|
-
secrets:
|
|
158
|
+
config: configStore(),
|
|
159
|
+
secrets: secretStore(),
|
|
159
160
|
systems: [],
|
|
160
161
|
logger,
|
|
161
162
|
debug: false,
|
|
@@ -176,8 +177,8 @@ describe('Hook Executor', () => {
|
|
|
176
177
|
|
|
177
178
|
await expect(
|
|
178
179
|
executeHookScript(scriptPath, {
|
|
179
|
-
config:
|
|
180
|
-
secrets:
|
|
180
|
+
config: configStore(),
|
|
181
|
+
secrets: secretStore(),
|
|
181
182
|
systems: [],
|
|
182
183
|
logger,
|
|
183
184
|
debug: false,
|
|
@@ -193,8 +194,8 @@ describe('Hook Executor', () => {
|
|
|
193
194
|
const scriptPath = join(FIXTURES_DIR, 'void-hook.ts');
|
|
194
195
|
|
|
195
196
|
const result = await executeHookScript(scriptPath, {
|
|
196
|
-
config:
|
|
197
|
-
secrets:
|
|
197
|
+
config: configStore(),
|
|
198
|
+
secrets: secretStore(),
|
|
198
199
|
systems: [],
|
|
199
200
|
logger,
|
|
200
201
|
debug: false,
|
|
@@ -212,8 +213,8 @@ describe('Hook Executor', () => {
|
|
|
212
213
|
|
|
213
214
|
await expect(
|
|
214
215
|
executeHookScript(scriptPath, {
|
|
215
|
-
config:
|
|
216
|
-
secrets:
|
|
216
|
+
config: configStore(),
|
|
217
|
+
secrets: secretStore(),
|
|
217
218
|
systems: [],
|
|
218
219
|
logger,
|
|
219
220
|
debug: false,
|
|
@@ -231,9 +232,9 @@ describe('Hook Executor', () => {
|
|
|
231
232
|
test('honors a caller-supplied idle timeout instead of the 30s default', async () => {
|
|
232
233
|
const { logger } = createCapturingLogger();
|
|
233
234
|
const scriptPath = join(FIXTURES_DIR, 'silent-hook.ts');
|
|
234
|
-
const context = {
|
|
235
|
-
config: { silent_ms: 8000 },
|
|
236
|
-
secrets:
|
|
235
|
+
const context: HookContext = {
|
|
236
|
+
config: configStore({ silent_ms: 8000 }),
|
|
237
|
+
secrets: secretStore(),
|
|
237
238
|
systems: [],
|
|
238
239
|
logger,
|
|
239
240
|
debug: false,
|
|
@@ -310,8 +311,8 @@ describe('Hook Executor', () => {
|
|
|
310
311
|
try {
|
|
311
312
|
const { logger } = createCapturingLogger();
|
|
312
313
|
await executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
|
|
313
|
-
config:
|
|
314
|
-
secrets:
|
|
314
|
+
config: configStore(),
|
|
315
|
+
secrets: secretStore(),
|
|
315
316
|
systems: [],
|
|
316
317
|
logger,
|
|
317
318
|
debug: false,
|
package/src/hooks/executor.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
61
61
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
62
62
|
import { startBroker } from './broker';
|
|
63
|
+
import type { HookStoresProvider } from './broker';
|
|
63
64
|
import {
|
|
64
65
|
HOOK_MOUNT_SET_ENV,
|
|
65
66
|
HOOK_PROTOCOL_VERSION,
|
|
@@ -169,6 +170,35 @@ const FORWARDED_ENV = [
|
|
|
169
170
|
'no_proxy',
|
|
170
171
|
] as const;
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The PATH a hook child gets: the CLI's own directory ahead of the inherited
|
|
175
|
+
* one, so the child can exec the SAME `celilo` the parent runs.
|
|
176
|
+
*
|
|
177
|
+
* A bun global install lands the `celilo` bin beside the bun binary itself
|
|
178
|
+
* (install.sh sets `BUN_INSTALL/bin`, default `~/.bun/bin`), so
|
|
179
|
+
* `dirname(process.execPath)` is that directory. The inherited PATH carries no
|
|
180
|
+
* such guarantee: the e2e management image runs the CLI only through an
|
|
181
|
+
* absolute-path wrapper at /usr/local/bin because its ENV has no /root/.bun/bin,
|
|
182
|
+
* and every hook that spawned `celilo` by name — caddy-internal's
|
|
183
|
+
* `on_consumer_removed` route withdrawal — died with "Executable not found in
|
|
184
|
+
* $PATH", leaving the stale routes it was withdrawing in place (celilo#1300).
|
|
185
|
+
* A real fleet hits the same class whenever the CLI is launched by a systemd
|
|
186
|
+
* unit or an absolute path with a minimal PATH.
|
|
187
|
+
*
|
|
188
|
+
* Already-present wins: an operator who has the directory on PATH sees an
|
|
189
|
+
* unchanged value. An absent inherited PATH still yields the CLI directory,
|
|
190
|
+
* so a hook can always reach `celilo` even from a stripped parent.
|
|
191
|
+
*
|
|
192
|
+
* Exported for the allow-list tests (hook-trespass.test.ts), which pin this
|
|
193
|
+
* contract the same way they pin the rest of the child environment.
|
|
194
|
+
*/
|
|
195
|
+
export function childPath(inherited: string | undefined): string {
|
|
196
|
+
const cliDir = dirname(process.execPath);
|
|
197
|
+
if (inherited === undefined) return cliDir;
|
|
198
|
+
if (inherited.split(':').includes(cliDir)) return inherited;
|
|
199
|
+
return `${cliDir}:${inherited}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
173
203
|
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
174
204
|
/**
|
|
@@ -291,6 +321,13 @@ export interface ExecuteHookOptions {
|
|
|
291
321
|
* fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
|
|
292
322
|
*/
|
|
293
323
|
remoteAccess?: RemoteAccessPolicy;
|
|
324
|
+
/**
|
|
325
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5), built
|
|
326
|
+
* lazily so a hook that never touches `context.secrets` / `context.config`
|
|
327
|
+
* pays nothing. Absent means the broker refuses store writes with an error
|
|
328
|
+
* naming the gap — never a silent drop.
|
|
329
|
+
*/
|
|
330
|
+
hookStores?: HookStoresProvider;
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
/**
|
|
@@ -333,6 +370,7 @@ export async function executeHookScript(
|
|
|
333
370
|
scriptPath,
|
|
334
371
|
logger,
|
|
335
372
|
onActivity: markActive,
|
|
373
|
+
stores: options.hookStores,
|
|
336
374
|
});
|
|
337
375
|
let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
|
|
338
376
|
|
|
@@ -516,6 +554,10 @@ export function hookChildEnv(
|
|
|
516
554
|
const env: Record<string, string> = {};
|
|
517
555
|
|
|
518
556
|
for (const name of FORWARDED_ENV) {
|
|
557
|
+
if (name === 'PATH') {
|
|
558
|
+
env.PATH = childPath(process.env.PATH);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
519
561
|
const value = process.env[name];
|
|
520
562
|
if (value !== undefined) env[name] = value;
|
|
521
563
|
}
|
|
@@ -564,19 +606,36 @@ function jailRequest(
|
|
|
564
606
|
|
|
565
607
|
/**
|
|
566
608
|
* Say what the jail did, once per run, at a level that matches how surprising
|
|
567
|
-
* it is.
|
|
609
|
+
* it is. A dropped row is now either FATAL or SILENT, never a warning.
|
|
610
|
+
*
|
|
611
|
+
* This used to warn about every absent row, because it could not tell a routine
|
|
612
|
+
* absence from a damaging one: "both look identical here, so the line names the
|
|
613
|
+
* paths and lets a reader tell them apart." That reasoning was sound and the
|
|
614
|
+
* result was not. The line fired 96 times with a byte-identical payload in a
|
|
615
|
+
* single `cele2e run --all`, so the case it existed to catch was buried in
|
|
616
|
+
* ninety-six copies of the case that does not matter. A signal that repeats
|
|
617
|
+
* unchanged is one every reader learns to skip.
|
|
618
|
+
*
|
|
619
|
+
* Each row now states what its own absence means (`MountAbsence`), so there is
|
|
620
|
+
* no classification left for a human to do from a path string:
|
|
568
621
|
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* here, so the line names the paths and lets a reader tell them apart.
|
|
622
|
+
* required throw. The hook cannot do what it was asked.
|
|
623
|
+
* declared-only silent until a module can declare the facility (ce-qani).
|
|
624
|
+
* runtime silent. A genuinely needed one fails the runtime, louder.
|
|
625
|
+
* conditional silent. Expected at this point in the lifecycle.
|
|
574
626
|
*/
|
|
575
627
|
function reportJail(plan: JailPlan, logger: HookLogger): void {
|
|
576
628
|
if (plan.mode === 'jailed') {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
629
|
+
const fatal = plan.skipped.filter((m) => m.absence === 'required');
|
|
630
|
+
if (fatal.length > 0) {
|
|
631
|
+
// Throw rather than warn. A dropped contract input means the hook writes
|
|
632
|
+
// into the run's PRIVATE TMPFS and reports success over a directory that
|
|
633
|
+
// is discarded when it exits (task 4.2j), and a dropped interpreter or
|
|
634
|
+
// module tree means it could never have run at all. Both were previously
|
|
635
|
+
// survivable-looking warnings.
|
|
636
|
+
const detail = fatal.map((m) => `${m.path} (${m.reason})`).join(', ');
|
|
637
|
+
throw new Error(
|
|
638
|
+
`Hook jail cannot be built: ${fatal.length} required mount(s) absent on this host: ${detail}`,
|
|
580
639
|
);
|
|
581
640
|
}
|
|
582
641
|
return;
|
|
@@ -642,6 +701,13 @@ export interface InvokeHookOptions {
|
|
|
642
701
|
* refused (Rule 6.4).
|
|
643
702
|
*/
|
|
644
703
|
remoteAccess?: RemoteAccessPolicy;
|
|
704
|
+
/**
|
|
705
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5), as a
|
|
706
|
+
* lazy provider: `() => createHookStores(db, moduleId)`. Built on first use
|
|
707
|
+
* inside the run, so a hook that never persists state never reads the
|
|
708
|
+
* manifest or touches the master key.
|
|
709
|
+
*/
|
|
710
|
+
hookStores?: HookStoresProvider;
|
|
645
711
|
}
|
|
646
712
|
|
|
647
713
|
/**
|
|
@@ -885,7 +951,11 @@ export async function invokeHook(
|
|
|
885
951
|
|
|
886
952
|
// Build context
|
|
887
953
|
const loadedCapabilities = options.capabilities ?? {};
|
|
888
|
-
|
|
954
|
+
// The store accessor methods attach on the CHILD side (hook-runner.ts,
|
|
955
|
+
// buildStoreView) where the socket is, so the object serialized here
|
|
956
|
+
// carries the map half only. Same cast rationale as the runner's own
|
|
957
|
+
// context build. (hook-owned-state task 3.5)
|
|
958
|
+
const context = {
|
|
889
959
|
...inputs,
|
|
890
960
|
config,
|
|
891
961
|
secrets,
|
|
@@ -895,7 +965,7 @@ export async function invokeHook(
|
|
|
895
965
|
screenshotDir,
|
|
896
966
|
stateDir,
|
|
897
967
|
capabilities: loadedCapabilities,
|
|
898
|
-
};
|
|
968
|
+
} as unknown as HookContext;
|
|
899
969
|
|
|
900
970
|
// Pre-flight: every required capability must be loaded (HOOK_API_V2 D3).
|
|
901
971
|
// Fail before invoking the handler so the script never sees a missing
|
|
@@ -945,6 +1015,7 @@ export async function invokeHook(
|
|
|
945
1015
|
idleTimeoutMs,
|
|
946
1016
|
jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
|
|
947
1017
|
remoteAccess: options.remoteAccess,
|
|
1018
|
+
hookStores: options.hookStores,
|
|
948
1019
|
});
|
|
949
1020
|
|
|
950
1021
|
// Validate outputs against the contract signature
|