@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.
Files changed (93) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +2 -2
  4. package/schemas/system_config.json +2 -1
  5. package/src/capabilities/public-web-publish.test.ts +61 -0
  6. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  7. package/src/cli/commands/firewall-interface-list.ts +73 -7
  8. package/src/cli/commands/machine-add.ts +12 -55
  9. package/src/cli/commands/module-config.test.ts +20 -1
  10. package/src/cli/commands/module-import.ts +1 -1
  11. package/src/cli/commands/module-update.test.ts +82 -0
  12. package/src/cli/commands/module-update.ts +14 -4
  13. package/src/cli/commands/monitor.ts +2 -10
  14. package/src/cli/commands/restore.ts +16 -6
  15. package/src/cli/generate-zsh-completion.ts +1 -1
  16. package/src/cli/index.ts +4 -3
  17. package/src/cli/restore-migration-failure.test.ts +159 -0
  18. package/src/db/client.ts +5 -0
  19. package/src/db/migrate.test.ts +61 -135
  20. package/src/db/migrate.ts +7 -2
  21. package/src/db/schema.ts +10 -0
  22. package/src/hooks/broker.test.ts +106 -2
  23. package/src/hooks/broker.ts +91 -1
  24. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  25. package/src/hooks/capability-loader.ts +15 -1
  26. package/src/hooks/define-hook.test.ts +4 -3
  27. package/src/hooks/executor.test.ts +19 -18
  28. package/src/hooks/executor.ts +82 -11
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  30. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  31. package/src/hooks/hook-protocol.ts +46 -1
  32. package/src/hooks/hook-runner.ts +36 -0
  33. package/src/hooks/hook-store-proxy.test.ts +109 -0
  34. package/src/hooks/hook-store-proxy.ts +85 -0
  35. package/src/hooks/hook-store.test.ts +162 -0
  36. package/src/hooks/hook-store.ts +290 -0
  37. package/src/hooks/hook-timeout.test.ts +3 -2
  38. package/src/hooks/hook-trespass.test.ts +29 -5
  39. package/src/hooks/jail.test.ts +1 -1
  40. package/src/hooks/jail.ts +14 -7
  41. package/src/hooks/mount-set.test.ts +208 -0
  42. package/src/hooks/mount-set.ts +62 -14
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  45. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  46. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  47. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  48. package/src/hooks/unjailed-lint.test.ts +22 -6
  49. package/src/manifest/schema.ts +1 -0
  50. package/src/module/packaging/build.ts +70 -2
  51. package/src/module/web-root.ts +17 -1
  52. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  53. package/src/policy/module-script-scan.test.ts +42 -1
  54. package/src/policy/module-script-scan.ts +275 -5
  55. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  56. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  57. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  58. package/src/registry/client.test.ts +149 -0
  59. package/src/registry/client.ts +203 -11
  60. package/src/services/alerting/format.test.ts +57 -0
  61. package/src/services/alerting/format.ts +24 -0
  62. package/src/services/alerting/run-monitor.ts +2 -2
  63. package/src/services/backup-create.ts +7 -7
  64. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  65. package/src/services/backup-restore.ts +8 -4
  66. package/src/services/bus-interview.ts +37 -14
  67. package/src/services/config-provenance.ts +4 -0
  68. package/src/services/control-plane-bootstrap.test.ts +121 -1
  69. package/src/services/control-plane-bootstrap.ts +51 -4
  70. package/src/services/deploy-preflight.ts +8 -2
  71. package/src/services/deploy-validation.test.ts +22 -0
  72. package/src/services/deploy-validation.ts +8 -0
  73. package/src/services/dns-discovery.test.ts +54 -0
  74. package/src/services/dns-discovery.ts +47 -5
  75. package/src/services/fleet-key.test.ts +66 -2
  76. package/src/services/fleet-key.ts +54 -0
  77. package/src/services/health-runner.ts +2 -0
  78. package/src/services/module-config.ts +20 -2
  79. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  80. package/src/services/module-deploy.ts +163 -1
  81. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  82. package/src/services/module-validator/git-hygiene.ts +83 -14
  83. package/src/services/restore-from-file.test.ts +20 -0
  84. package/src/services/restore-from-file.ts +21 -6
  85. package/src/services/static-content-converge.test.ts +140 -2
  86. package/src/services/static-content-converge.ts +55 -8
  87. package/src/services/system-config-schema-types.ts +1 -1
  88. package/src/services/system-config-validator.test.ts +36 -0
  89. package/src/services/system-config-validator.ts +11 -0
  90. package/src/services/trusted-sources.test.ts +30 -0
  91. package/src/services/trusted-sources.ts +47 -10
  92. package/src/templates/generator.ts +9 -2
  93. package/src/variables/context.ts +16 -5
@@ -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
+ });
@@ -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: 1,
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 type { HookDefinition } from './types';
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,
@@ -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
- * `skipped` is a warning rather than debug output on purpose. A dropped
570
- * `/lib64` on arm64 is routine; a dropped contract input means the hook is
571
- * about to write into the run's private tmpfs and report success over a
572
- * directory that is discarded when it exits (task 4.2j). Both look identical
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
- if (plan.skipped.length > 0) {
578
- logger.warn(
579
- `Hook jail: ${plan.skipped.length} mount(s) absent on this host and dropped: ${plan.skipped.join(', ')}`,
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
- const context: HookContext = {
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