@celilo/cli 2.1.0 → 2.2.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.
Files changed (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -1,8 +1,12 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { closeDb, getDb } from '../db/client';
6
+ import { runMigrations } from '../db/migrate';
7
+ import { systemConfig } from '../db/schema';
5
8
  import type { ContractHookSignature } from '../manifest/contracts';
9
+ import { resetTestDbPath } from '../test-utils/db-path';
6
10
  import {
7
11
  checkRequiredCapabilities,
8
12
  declaredPathInputs,
@@ -15,7 +19,8 @@ import {
15
19
  } from './executor';
16
20
  import { readJailMode } from './jail';
17
21
  import { createCapturingLogger } from './logger';
18
- import type { HookDefinition } from './types';
22
+ import { configStore, secretStore } from './test-fixtures/store-backed';
23
+ import type { HookContext, HookDefinition } from './types';
19
24
 
20
25
  const FIXTURES_DIR = join(__dirname, 'test-fixtures');
21
26
 
@@ -115,8 +120,8 @@ describe('Hook Executor', () => {
115
120
  const scriptPath = join(FIXTURES_DIR, 'success-hook.ts');
116
121
 
117
122
  const result = await executeHookScript(scriptPath, {
118
- config: { username: 'testuser' },
119
- secrets: { password: 'secret' },
123
+ config: configStore({ username: 'testuser' }),
124
+ secrets: secretStore({ password: 'secret' }),
120
125
  systems: [],
121
126
  logger,
122
127
  debug: false,
@@ -136,8 +141,8 @@ describe('Hook Executor', () => {
136
141
 
137
142
  await expect(
138
143
  executeHookScript('/nonexistent/hook.ts', {
139
- config: {},
140
- secrets: {},
144
+ config: configStore(),
145
+ secrets: secretStore(),
141
146
  systems: [],
142
147
  logger,
143
148
  debug: false,
@@ -154,8 +159,8 @@ describe('Hook Executor', () => {
154
159
 
155
160
  await expect(
156
161
  executeHookScript(scriptPath, {
157
- config: {},
158
- secrets: {},
162
+ config: configStore(),
163
+ secrets: secretStore(),
159
164
  systems: [],
160
165
  logger,
161
166
  debug: false,
@@ -176,8 +181,8 @@ describe('Hook Executor', () => {
176
181
 
177
182
  await expect(
178
183
  executeHookScript(scriptPath, {
179
- config: {},
180
- secrets: {},
184
+ config: configStore(),
185
+ secrets: secretStore(),
181
186
  systems: [],
182
187
  logger,
183
188
  debug: false,
@@ -193,8 +198,8 @@ describe('Hook Executor', () => {
193
198
  const scriptPath = join(FIXTURES_DIR, 'void-hook.ts');
194
199
 
195
200
  const result = await executeHookScript(scriptPath, {
196
- config: {},
197
- secrets: {},
201
+ config: configStore(),
202
+ secrets: secretStore(),
198
203
  systems: [],
199
204
  logger,
200
205
  debug: false,
@@ -212,8 +217,8 @@ describe('Hook Executor', () => {
212
217
 
213
218
  await expect(
214
219
  executeHookScript(scriptPath, {
215
- config: {},
216
- secrets: {},
220
+ config: configStore(),
221
+ secrets: secretStore(),
217
222
  systems: [],
218
223
  logger,
219
224
  debug: false,
@@ -231,9 +236,9 @@ describe('Hook Executor', () => {
231
236
  test('honors a caller-supplied idle timeout instead of the 30s default', async () => {
232
237
  const { logger } = createCapturingLogger();
233
238
  const scriptPath = join(FIXTURES_DIR, 'silent-hook.ts');
234
- const context = {
235
- config: { silent_ms: 8000 },
236
- secrets: {},
239
+ const context: HookContext = {
240
+ config: configStore({ silent_ms: 8000 }),
241
+ secrets: secretStore(),
237
242
  systems: [],
238
243
  logger,
239
244
  debug: false,
@@ -310,8 +315,8 @@ describe('Hook Executor', () => {
310
315
  try {
311
316
  const { logger } = createCapturingLogger();
312
317
  await executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
313
- config: {},
314
- secrets: {},
318
+ config: configStore(),
319
+ secrets: secretStore(),
315
320
  systems: [],
316
321
  logger,
317
322
  debug: false,
@@ -328,6 +333,88 @@ describe('Hook Executor', () => {
328
333
  });
329
334
  });
330
335
 
336
+ describe('the executor resolves the jail policy through stored config (hook-jail-config-surface slice 4)', () => {
337
+ // Each test gets its own migrated scratch database and its own jail-mode
338
+ // store, so a planted row here never leaks into another test (or another
339
+ // host). With no module tree in `options.jail`, `planJailedSpawn` treats a
340
+ // `required` policy as a hard failure on EVERY host — which makes the
341
+ // resolution outcome observable without depending on what jail backend a
342
+ // given machine happens to have.
343
+ let dir: string;
344
+ const saved: Record<string, string | undefined> = {};
345
+
346
+ const plantPolicyRow = (value: string): void => {
347
+ getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run();
348
+ };
349
+
350
+ const runVoidHook = (): Promise<Record<string, unknown>> => {
351
+ const { logger } = createCapturingLogger();
352
+ return executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
353
+ config: configStore(),
354
+ secrets: secretStore(),
355
+ systems: [],
356
+ logger,
357
+ debug: false,
358
+ screenshotDir: '/tmp',
359
+ stateDir: '/tmp',
360
+ capabilities: {},
361
+ });
362
+ };
363
+
364
+ beforeEach(async () => {
365
+ for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) {
366
+ saved[key] = process.env[key];
367
+ }
368
+ dir = mkdtempSync(join(tmpdir(), 'celilo-executor-policy-'));
369
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
370
+ process.env.CELILO_HOOK_JAIL_MODE_PATH = join(dir, 'mode.json');
371
+ delete process.env.CELILO_HOOK_JAIL;
372
+ await runMigrations(process.env.CELILO_DB_PATH);
373
+ });
374
+
375
+ afterEach(() => {
376
+ closeDb();
377
+ // The db path resets to the scratch path rather than restoring the
378
+ // saved value: that value may be another suite's temp database, or
379
+ // unset — which sends the next var-less reader to the operator's real
380
+ // celilo.db (celilo#1315).
381
+ resetTestDbPath();
382
+ for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) {
383
+ if (saved[key] === undefined) delete process.env[key];
384
+ else process.env[key] = saved[key];
385
+ }
386
+ rmSync(dir, { recursive: true, force: true });
387
+ });
388
+
389
+ test('a stored row decides when the env is silent', async () => {
390
+ plantPolicyRow('required');
391
+ await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/);
392
+ });
393
+
394
+ test('env wins over a conflicting stored row', async () => {
395
+ process.env.CELILO_HOOK_JAIL = 'off';
396
+ plantPolicyRow('required');
397
+ await expect(runVoidHook()).resolves.toBeDefined();
398
+ });
399
+
400
+ test('env=required still reaches the planner when the row says off', async () => {
401
+ process.env.CELILO_HOOK_JAIL = 'required';
402
+ plantPolicyRow('off');
403
+ await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/);
404
+ });
405
+
406
+ test('a bad stored row fails the hook instead of running it unjailed', async () => {
407
+ plantPolicyRow('alwayssafe');
408
+ await expect(runVoidHook()).rejects.toThrow(/hooks\.jail_policy='alwayssafe'/);
409
+ });
410
+
411
+ test('nothing set resolves to the default, off (peba’s ruling on ce-rez7)', async () => {
412
+ // Migrated DB with no row: the stored-row read returns undefined and the
413
+ // resolver falls through to the default, which jails nobody.
414
+ await expect(runVoidHook()).resolves.toBeDefined();
415
+ });
416
+ });
417
+
331
418
  describe('resolveHookTimeouts', () => {
332
419
  test('no declaration: 60s total, 30s idle heuristic', () => {
333
420
  expect(resolveHookTimeouts(undefined, false)).toEqual({
@@ -51,6 +51,8 @@ import {
51
51
  moduleArtifactDir,
52
52
  moduleStateDir,
53
53
  } from '@celilo/capabilities';
54
+ import { getDbPath } from '../config/paths';
55
+ import { getDb } from '../db/client';
54
56
  import {
55
57
  type ContractHookSignature,
56
58
  contractHookSignature,
@@ -60,6 +62,7 @@ import {
60
62
  import { isPrivilegedCapability } from '../manifest/validate';
61
63
  import { pruneModuleArtifacts } from './artifact-retention';
62
64
  import { startBroker } from './broker';
65
+ import type { HookStoresProvider } from './broker';
63
66
  import {
64
67
  HOOK_MOUNT_SET_ENV,
65
68
  HOOK_PROTOCOL_VERSION,
@@ -72,10 +75,10 @@ import {
72
75
  import {
73
76
  type JailPlan,
74
77
  detectJailBackend,
75
- jailPolicy,
76
78
  planJailedSpawn,
77
79
  realpathRequest,
78
80
  recordJailMode,
81
+ resolveJailPolicy,
79
82
  runtimeModulePathsFor,
80
83
  } from './jail';
81
84
  import {
@@ -169,6 +172,50 @@ const FORWARDED_ENV = [
169
172
  'no_proxy',
170
173
  ] as const;
171
174
 
175
+ /**
176
+ * The directory holding the `celilo` bin of the running CLI package, resolved
177
+ * from this file's own location: src/hooks -> the package's bin/. Every
178
+ * distribution layout lands there. The .deb wrapper execs
179
+ * `$CELILO_HOME/node_modules/@celilo/cli/bin/celilo` directly (packaging/celilo/
180
+ * scripts/wrapper.sh), and a bun global install symlinks ~/.bun/bin/celilo into
181
+ * that same bin directory, whose script self-locates its package dir.
182
+ */
183
+ export const CLI_BIN_DIR = join(import.meta.dir, '..', '..', 'bin');
184
+
185
+ /**
186
+ * The PATH a hook child gets: the CLI package's own bin directory, then the
187
+ * runtime's, ahead of the inherited one — so the child can exec the SAME
188
+ * `celilo` the parent runs, and that celilo can still find its interpreter.
189
+ *
190
+ * `dirname(process.execPath)` alone is NOT the CLI's directory under the fleet
191
+ * layout: the .deb runs the CLI through a wrapper at /usr/local/bin/celilo that
192
+ * execs bun on `node_modules/@celilo/cli/src/cli/index.ts`, so the directory
193
+ * beside the bun binary holds only bun (measured on the fleet, 2026-09-07,
194
+ * ce-y0fd). Hooks then found `celilo` only because the systemd unit happened to
195
+ * inherit /usr/local/bin on its PATH — the same accident that broke every hook
196
+ * spawning `celilo` by name in the e2e management image (celilo#1300).
197
+ * CLI_BIN_DIR resolves where the bin actually lives, whatever layout launched
198
+ * the parent, and comes FIRST so `celilo` is the package's own bin rather than
199
+ * an operator's wrapper. The runtime directory stays second because bin/celilo
200
+ * needs `command -v bun` (it is bash, not a shebanged TS file).
201
+ *
202
+ * Already-present wins: an operator who has a directory on PATH sees an
203
+ * unchanged value. An absent inherited PATH still yields both directories, so
204
+ * a hook can always reach `celilo` even from a stripped parent; bash resolves
205
+ * through the loader's default search.
206
+ *
207
+ * Exported for the allow-list tests (hook-trespass.test.ts), which pin this
208
+ * contract the same way they pin the rest of the child environment.
209
+ */
210
+ export function childPath(inherited: string | undefined): string {
211
+ const base = inherited === undefined ? [] : inherited.split(':');
212
+ const prepended: string[] = [];
213
+ for (const dir of [CLI_BIN_DIR, dirname(process.execPath)]) {
214
+ if (!base.includes(dir) && !prepended.includes(dir)) prepended.push(dir);
215
+ }
216
+ return [...prepended, ...base].join(':');
217
+ }
218
+
172
219
  /** The shim celilo spawns. Resolved from here so an npm install finds it too. */
173
220
  const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
174
221
  /**
@@ -291,6 +338,32 @@ export interface ExecuteHookOptions {
291
338
  * fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
292
339
  */
293
340
  remoteAccess?: RemoteAccessPolicy;
341
+ /**
342
+ * The module's hook-owned-state stores (hook-owned-state task 3.5), built
343
+ * lazily so a hook that never touches `context.secrets` / `context.config`
344
+ * pays nothing. Absent means the broker refuses store writes with an error
345
+ * naming the gap — never a silent drop.
346
+ */
347
+ hookStores?: HookStoresProvider;
348
+ }
349
+
350
+ /**
351
+ * The stored `hooks.jail_policy` value, or undefined when there is no row or
352
+ * no celilo DB to ask (a dev box). Same row-read pattern as
353
+ * `ipam/auto-allocator.ts` and the executor's fellow consumer
354
+ * `cli/commands/system-doctor.ts`; the drizzle table is `systemConfig` in
355
+ * `db/schema.ts`.
356
+ *
357
+ * Unlike `remoteAccess`, this read happens here rather than through a
358
+ * caller-built input because D2 names the executor itself as one of the two
359
+ * consumers that fetch the stored row and hand it to the pure resolver.
360
+ */
361
+ function readStoredJailPolicy(): string | undefined {
362
+ if (!existsSync(getDbPath())) return undefined;
363
+ const row = getDb()
364
+ .$client.prepare('SELECT value FROM system_config WHERE key = ?')
365
+ .get('hooks.jail_policy') as { value: string } | undefined;
366
+ return row?.value;
294
367
  }
295
368
 
296
369
  /**
@@ -333,6 +406,7 @@ export async function executeHookScript(
333
406
  scriptPath,
334
407
  logger,
335
408
  onActivity: markActive,
409
+ stores: options.hookStores,
336
410
  });
337
411
  let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
338
412
 
@@ -356,7 +430,7 @@ export async function executeHookScript(
356
430
  [process.execPath, HOOK_RUNNER_SPAWN_PATH],
357
431
  mountSet,
358
432
  detectJailBackend(),
359
- jailPolicy(),
433
+ resolveJailPolicy(process.env.CELILO_HOOK_JAIL, readStoredJailPolicy()).policy,
360
434
  );
361
435
  reportJail(jail, logger);
362
436
  // Only a real module hook says anything about whether this HOST jails. An
@@ -516,6 +590,10 @@ export function hookChildEnv(
516
590
  const env: Record<string, string> = {};
517
591
 
518
592
  for (const name of FORWARDED_ENV) {
593
+ if (name === 'PATH') {
594
+ env.PATH = childPath(process.env.PATH);
595
+ continue;
596
+ }
519
597
  const value = process.env[name];
520
598
  if (value !== undefined) env[name] = value;
521
599
  }
@@ -564,19 +642,36 @@ function jailRequest(
564
642
 
565
643
  /**
566
644
  * Say what the jail did, once per run, at a level that matches how surprising
567
- * it is.
645
+ * it is. A dropped row is now either FATAL or SILENT, never a warning.
568
646
  *
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.
647
+ * This used to warn about every absent row, because it could not tell a routine
648
+ * absence from a damaging one: "both look identical here, so the line names the
649
+ * paths and lets a reader tell them apart." That reasoning was sound and the
650
+ * result was not. The line fired 96 times with a byte-identical payload in a
651
+ * single `cele2e run --all`, so the case it existed to catch was buried in
652
+ * ninety-six copies of the case that does not matter. A signal that repeats
653
+ * unchanged is one every reader learns to skip.
654
+ *
655
+ * Each row now states what its own absence means (`MountAbsence`), so there is
656
+ * no classification left for a human to do from a path string:
657
+ *
658
+ * required throw. The hook cannot do what it was asked.
659
+ * declared-only silent until a module can declare the facility (ce-qani).
660
+ * runtime silent. A genuinely needed one fails the runtime, louder.
661
+ * conditional silent. Expected at this point in the lifecycle.
574
662
  */
575
663
  function reportJail(plan: JailPlan, logger: HookLogger): void {
576
664
  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(', ')}`,
665
+ const fatal = plan.skipped.filter((m) => m.absence === 'required');
666
+ if (fatal.length > 0) {
667
+ // Throw rather than warn. A dropped contract input means the hook writes
668
+ // into the run's PRIVATE TMPFS and reports success over a directory that
669
+ // is discarded when it exits (task 4.2j), and a dropped interpreter or
670
+ // module tree means it could never have run at all. Both were previously
671
+ // survivable-looking warnings.
672
+ const detail = fatal.map((m) => `${m.path} (${m.reason})`).join(', ');
673
+ throw new Error(
674
+ `Hook jail cannot be built: ${fatal.length} required mount(s) absent on this host: ${detail}`,
580
675
  );
581
676
  }
582
677
  return;
@@ -642,6 +737,13 @@ export interface InvokeHookOptions {
642
737
  * refused (Rule 6.4).
643
738
  */
644
739
  remoteAccess?: RemoteAccessPolicy;
740
+ /**
741
+ * The module's hook-owned-state stores (hook-owned-state task 3.5), as a
742
+ * lazy provider: `() => createHookStores(db, moduleId)`. Built on first use
743
+ * inside the run, so a hook that never persists state never reads the
744
+ * manifest or touches the master key.
745
+ */
746
+ hookStores?: HookStoresProvider;
645
747
  }
646
748
 
647
749
  /**
@@ -885,7 +987,11 @@ export async function invokeHook(
885
987
 
886
988
  // Build context
887
989
  const loadedCapabilities = options.capabilities ?? {};
888
- const context: HookContext = {
990
+ // The store accessor methods attach on the CHILD side (hook-runner.ts,
991
+ // buildStoreView) where the socket is, so the object serialized here
992
+ // carries the map half only. Same cast rationale as the runner's own
993
+ // context build. (hook-owned-state task 3.5)
994
+ const context = {
889
995
  ...inputs,
890
996
  config,
891
997
  secrets,
@@ -895,7 +1001,7 @@ export async function invokeHook(
895
1001
  screenshotDir,
896
1002
  stateDir,
897
1003
  capabilities: loadedCapabilities,
898
- };
1004
+ } as unknown as HookContext;
899
1005
 
900
1006
  // Pre-flight: every required capability must be loaded (HOOK_API_V2 D3).
901
1007
  // Fail before invoking the handler so the script never sees a missing
@@ -945,6 +1051,7 @@ export async function invokeHook(
945
1051
  idleTimeoutMs,
946
1052
  jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
947
1053
  remoteAccess: options.remoteAccess,
1054
+ hookStores: options.hookStores,
948
1055
  });
949
1056
 
950
1057
  // Validate outputs against the contract signature
@@ -37,6 +37,7 @@ import { dirname, join, resolve } from 'node:path';
37
37
  import { executeHookScript } from './executor';
38
38
  import { detectJailBackend } from './jail';
39
39
  import { createCapturingLogger } from './logger';
40
+ import { configStore, secretStore } from './test-fixtures/store-backed';
40
41
  import type { HookContext } from './types';
41
42
 
42
43
  const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts');
@@ -115,8 +116,8 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
115
116
 
116
117
  const { logger } = createCapturingLogger();
117
118
  const context: HookContext = {
118
- config: PROBE_BROWSER ? { browser_executable: PROBE_BROWSER } : {},
119
- secrets: {},
119
+ config: PROBE_BROWSER ? configStore({ browser_executable: PROBE_BROWSER }) : configStore(),
120
+ secrets: secretStore(),
120
121
  systems: [],
121
122
  logger,
122
123
  debug: false,
@@ -35,6 +35,7 @@ const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
35
35
  import { executeHookScript } from './executor';
36
36
  import { detectJailBackend, jailPolicy } from './jail';
37
37
  import { createCapturingLogger } from './logger';
38
+ import { configStore, secretStore } from './test-fixtures/store-backed';
38
39
  import type { HookContext } from './types';
39
40
 
40
41
  const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts');
@@ -110,12 +111,12 @@ async function runProbe(): Promise<Rig> {
110
111
 
111
112
  const { logger } = createCapturingLogger();
112
113
  const context: HookContext = {
113
- config: {
114
+ config: configStore({
114
115
  planted_secret: plantedSecret,
115
116
  sibling_file: siblingFile,
116
117
  staged_input: stagedInput,
117
- },
118
- secrets: {},
118
+ }),
119
+ secrets: secretStore(),
119
120
  systems: [],
120
121
  logger,
121
122
  debug: false,
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { z } from 'zod';
27
27
 
28
- export const HOOK_PROTOCOL_VERSION = 1;
28
+ export const HOOK_PROTOCOL_VERSION = 2;
29
29
 
30
30
  /** Environment variable carrying the broker's socket path to the child. */
31
31
  export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
@@ -91,6 +91,39 @@ export const CallFrameSchema = z.object({
91
91
  args: z.array(z.unknown()),
92
92
  });
93
93
 
94
+ /**
95
+ * One hook-owned-state store operation, correlated like a `call` (and answered
96
+ * by the same `return`/`throw` frames — the runner's pending map keys on `id`
97
+ * alone).
98
+ *
99
+ * `store` names which of the module's two stores the call targets; `method` is
100
+ * one of the four `HookStore` operations. A `transaction` call carries the
101
+ * buffered operation list the CHILD accumulated — the hook's `fn` ran entirely
102
+ * child-side, so a throwing `fn` never sends anything, which is what makes the
103
+ * discard real rather than a promise the parent has to honor.
104
+ *
105
+ * Hook-owned-state design: the accessor is implemented once broker-side and
106
+ * reaches the hook as its own RPC family over this envelope, NOT as a fake
107
+ * capability — a fake one would surface in the `capabilities` shape frame and
108
+ * appear to the hook as a provider module.
109
+ */
110
+ export const StoreCallFrameSchema = z.object({
111
+ type: z.literal('store'),
112
+ id: z.string(),
113
+ store: z.enum(['secrets', 'config']),
114
+ method: z.enum(['get', 'set', 'delete', 'transaction']),
115
+ args: z.array(z.unknown()),
116
+ });
117
+
118
+ /** One buffered operation inside a `transaction` store call. */
119
+ export const BufferedStoreOpSchema = z.object({
120
+ op: z.enum(['set', 'delete']),
121
+ name: z.string(),
122
+ value: z.string().optional(),
123
+ });
124
+
125
+ export type BufferedStoreOp = z.infer<typeof BufferedStoreOpSchema>;
126
+
94
127
  /** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */
95
128
  export const LogFrameSchema = z.object({
96
129
  type: z.literal('log'),
@@ -113,6 +146,7 @@ export const HookThrewFrameSchema = z.object({
113
146
  export const ChildFrameSchema = z.discriminatedUnion('type', [
114
147
  ReadyFrameSchema,
115
148
  CallFrameSchema,
149
+ StoreCallFrameSchema,
116
150
  LogFrameSchema,
117
151
  ResultFrameSchema,
118
152
  HookThrewFrameSchema,
@@ -145,6 +179,11 @@ export const MountEntrySchema = z.object({
145
179
  path: z.string(),
146
180
  mode: z.enum(['ro', 'rw', 'tmpfs']),
147
181
  reason: z.string(),
182
+ // Kept on the wire so this stays the structural twin its docblock below
183
+ // claims. The child ignores it — absence is decided when the set is derived,
184
+ // not by the hook — but a twin that has quietly stopped being one is how the
185
+ // round-trip test starts asserting about a shape nothing sends.
186
+ absence: z.enum(['required', 'declared-only', 'runtime', 'conditional']),
148
187
  });
149
188
 
150
189
  /** The derived mount set, on the wire. Structural twin of `MountSet`. */
@@ -272,6 +311,12 @@ export function createLineReader(onLine: (line: string) => void): (chunk: string
272
311
  /**
273
312
  * The handshake check, in one place so both ends produce the same sentence.
274
313
  *
314
+ * Version 2 added the `store` frame family (hook-owned-state task 3.5). Both
315
+ * ends of this protocol ship in the same celilo binary, so the check exists
316
+ * for one situation: a runner answering a broker built from different source
317
+ * than itself. It aborts the hook loudly instead of letting a store write
318
+ * disappear into a frame the other side cannot parse.
319
+ *
275
320
  * Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a
276
321
  * module's bundled copy was not), and the operator needs to know which side is
277
322
  * which to fix it.
@@ -26,6 +26,7 @@ import {
26
26
  serializeError,
27
27
  versionMismatch,
28
28
  } from './hook-protocol';
29
+ import { buildStoreView } from './hook-store-proxy';
29
30
  import type { HookContext, HookLogger } from './types';
30
31
  import { forwardLintWarnings } from './unjailed-lint';
31
32
 
@@ -110,6 +111,22 @@ function callBroker(capability: string, method: string, args: unknown[]): Promis
110
111
  });
111
112
  }
112
113
 
114
+ /**
115
+ * Store calls share the capability call's correlation machinery: the broker
116
+ * answers both families with the same `return`/`throw` frames, keyed by `id`.
117
+ */
118
+ function callStore(
119
+ store: 'secrets' | 'config',
120
+ method: 'get' | 'set' | 'delete' | 'transaction',
121
+ args: unknown[],
122
+ ): Promise<unknown> {
123
+ const id = `c${nextCallId++}`;
124
+ return new Promise((resolve, reject) => {
125
+ pending.set(id, { resolve, reject });
126
+ send({ type: 'store', id, store, method, args });
127
+ });
128
+ }
129
+
113
130
  /**
114
131
  * The `defineHook` brand check, moved here from the executor unchanged
115
132
  * (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives
@@ -133,8 +150,27 @@ async function runHook(): Promise<void> {
133
150
  // hook's logger from here on (anything earlier was buffered).
134
151
  forwardLintWarnings(logger.warn);
135
152
 
153
+ // Attach the hook-owned-state accessor onto both maps. The maps crossed
154
+ // the context frame as plain data; the accessor methods exist only on
155
+ // this side, where the socket is. Old map reads keep working; new code
156
+ // calls `secrets.set(...)` / `config.get(...)` (hook-owned-state task 3.5).
157
+ // The casts say something true: celilo built both fields and controls
158
+ // their shape, the same rationale as the field check above.
159
+ const stores = buildStoreView(
160
+ 'secrets',
161
+ (contextData.secrets ?? {}) as Record<string, string>,
162
+ callStore,
163
+ );
164
+ const configStores = buildStoreView(
165
+ 'config',
166
+ (contextData.config ?? {}) as Record<string, unknown>,
167
+ callStore,
168
+ );
169
+
136
170
  const context = {
137
171
  ...contextData,
172
+ config: configStores,
173
+ secrets: stores,
138
174
  logger,
139
175
  capabilities: buildCapabilities(shape),
140
176
  } as unknown as HookContext;