@celilo/cli 2.0.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 (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -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
 
@@ -367,6 +405,12 @@ export async function executeHookScript(
367
405
 
368
406
  const child = Bun.spawn({
369
407
  cmd: [...jail.cmd],
408
+ // `sandbox-exec` has no `--chdir`, so the plan names one and the spawn
409
+ // sets it. Without it the child inherits the operator's shell directory,
410
+ // which the profile does not name — and `bun` reads its cwd before it
411
+ // runs anything, so the hook dies with a message about nothing in
412
+ // particular. bubblewrap plans leave this absent and use `--chdir`.
413
+ ...(jail.cwd ? { cwd: jail.cwd } : {}),
370
414
  env: hookChildEnv(
371
415
  broker.socketPath,
372
416
  remoteBroker.socketPath,
@@ -510,6 +554,10 @@ export function hookChildEnv(
510
554
  const env: Record<string, string> = {};
511
555
 
512
556
  for (const name of FORWARDED_ENV) {
557
+ if (name === 'PATH') {
558
+ env.PATH = childPath(process.env.PATH);
559
+ continue;
560
+ }
513
561
  const value = process.env[name];
514
562
  if (value !== undefined) env[name] = value;
515
563
  }
@@ -558,19 +606,36 @@ function jailRequest(
558
606
 
559
607
  /**
560
608
  * Say what the jail did, once per run, at a level that matches how surprising
561
- * 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:
562
621
  *
563
- * `skipped` is a warning rather than debug output on purpose. A dropped
564
- * `/lib64` on arm64 is routine; a dropped contract input means the hook is
565
- * about to write into the run's private tmpfs and report success over a
566
- * directory that is discarded when it exits (task 4.2j). Both look identical
567
- * 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.
568
626
  */
569
627
  function reportJail(plan: JailPlan, logger: HookLogger): void {
570
628
  if (plan.mode === 'jailed') {
571
- if (plan.skipped.length > 0) {
572
- logger.warn(
573
- `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}`,
574
639
  );
575
640
  }
576
641
  return;
@@ -636,6 +701,13 @@ export interface InvokeHookOptions {
636
701
  * refused (Rule 6.4).
637
702
  */
638
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;
639
711
  }
640
712
 
641
713
  /**
@@ -879,7 +951,11 @@ export async function invokeHook(
879
951
 
880
952
  // Build context
881
953
  const loadedCapabilities = options.capabilities ?? {};
882
- 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 = {
883
959
  ...inputs,
884
960
  config,
885
961
  secrets,
@@ -889,7 +965,7 @@ export async function invokeHook(
889
965
  screenshotDir,
890
966
  stateDir,
891
967
  capabilities: loadedCapabilities,
892
- };
968
+ } as unknown as HookContext;
893
969
 
894
970
  // Pre-flight: every required capability must be loaded (HOOK_API_V2 D3).
895
971
  // Fail before invoking the handler so the script never sees a missing
@@ -939,6 +1015,7 @@ export async function invokeHook(
939
1015
  idleTimeoutMs,
940
1016
  jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
941
1017
  remoteAccess: options.remoteAccess,
1018
+ hookStores: options.hookStores,
942
1019
  });
943
1020
 
944
1021
  // 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,
@@ -125,22 +126,38 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
125
126
  capabilities: {},
126
127
  };
127
128
 
128
- const reach = (await executeHookScript(PROBE_HOOK, context, {
129
- timeoutMs: 60_000,
130
- idleTimeoutMs: 60_000,
131
- jail: {
132
- modulePath,
133
- // The browser reaches the jail as a DECLARED PATH INPUT, which is the
134
- // mechanism 4.10's fix would use. Binding it by editing the derivation
135
- // and then asking the derivation whether it is bound would prove
136
- // nothing.
137
- pathInputs: PROBE_BROWSER
138
- ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
139
- : [],
140
- },
141
- })) as unknown as Reach;
142
-
143
- return { reach, cleanup: () => rmSync(scratch, { recursive: true, force: true }) };
129
+ // The jailed run jails under `required`: ce-29z made `auto` defer on
130
+ // sandbox-exec until D14 exists, so this suite's jail is the operator's
131
+ // explicit act — the bypass the deferral deliberately leaves open.
132
+ const savedPolicy = process.env.CELILO_HOOK_JAIL;
133
+ process.env.CELILO_HOOK_JAIL = 'required';
134
+ try {
135
+ const reach = (await executeHookScript(PROBE_HOOK, context, {
136
+ timeoutMs: 60_000,
137
+ idleTimeoutMs: 60_000,
138
+ jail: {
139
+ modulePath,
140
+ // The browser reaches the jail as a DECLARED PATH INPUT, which is the
141
+ // mechanism 4.10's fix would use. Binding it by editing the derivation
142
+ // and then asking the derivation whether it is bound would prove
143
+ // nothing.
144
+ pathInputs: PROBE_BROWSER
145
+ ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
146
+ : [],
147
+ },
148
+ })) as unknown as Reach;
149
+
150
+ return { reach, cleanup: restorePolicy };
151
+ } catch (error) {
152
+ restorePolicy();
153
+ throw error;
154
+ }
155
+
156
+ function restorePolicy() {
157
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
158
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
159
+ rmSync(scratch, { recursive: true, force: true });
160
+ }
144
161
  }
145
162
 
146
163
  describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => {
@@ -157,18 +174,46 @@ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.ba
157
174
  console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`);
158
175
  }
159
176
 
160
- // ── The finding ────────────────────────────────────────────────────
161
- // No shell, so `execSync` cannot run and neither can any hook that uses
162
- // it. 22 module script files import `node:child_process`.
163
- expect(reach.shell.succeeded).toBe(false);
164
- expect(reach.exec_without_shell.succeeded).toBe(false);
177
+ // ── The finding, per backend ────────────────────────────────────────
178
+ // bubblewrap BUILDS a namespace, so reach is filesystem absence: no
179
+ // `/bin` is bound, so no shell, so `execSync` cannot run at all. 22
180
+ // module script files import `node:child_process`.
181
+ //
182
+ // `sandbox-exec` FILTERS the tree that is already there, and its parity
183
+ // statements change what exec means. `(allow process*)` lets the kernel
184
+ // map and run an image the jail cannot READ — exec is not file-read in
185
+ // SBPL — so `/bin/sh` runs, `/bin/echo` runs, and `ssh` spawns. Measured
186
+ // 2026-08-30, first run of this suite under the second backend. The
187
+ // boundary that DOES hold on macOS is the filesystem and the credential:
188
+ // writes outside the mount set are EPERM, secrets are unreadable, and
189
+ // the `~/.ssh` key stage 3 withheld is not in the jail for ssh to use —
190
+ // asserted by hook-trespass.test.ts and hook-jail-unreachability.test.ts,
191
+ // which pass under both backends.
192
+ if (availability.backend === 'sandbox-exec') {
193
+ expect(reach.shell.succeeded).toBe(true);
194
+ expect(reach.exec_without_shell.succeeded).toBe(true);
195
+ // It spawns; it cannot authenticate. That is stage 3's boundary, not
196
+ // this suite's.
197
+ expect(reach.ssh.succeeded).toBe(true);
198
+ // `ansible-playbook` is not installed on the probe host, so the row
199
+ // measures the HOST, not the jail: posix_spawn of anything present is
200
+ // allowed. No assertion either way.
201
+ // `dns_lookup` also stays a printed row rather than an assertion:
202
+ // measured REACHED — `(allow network*)` plus macOS resolving
203
+ // out-of-process in mDNSResponder — but gating on it needs a live
204
+ // resolver, which a unit suite should not require.
205
+ } else {
206
+ expect(reach.shell.succeeded).toBe(false);
207
+ expect(reach.exec_without_shell.succeeded).toBe(false);
165
208
 
166
- // `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts`
167
- // keeps working. There is no `ssh` to hand that key to.
168
- expect(reach.ssh.succeeded).toBe(false);
169
- expect(reach.ansible.succeeded).toBe(false);
209
+ // `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts`
210
+ // keeps working. There is no `ssh` to hand that key to.
211
+ expect(reach.ssh.succeeded).toBe(false);
212
+ expect(reach.ansible.succeeded).toBe(false);
213
+ }
170
214
 
171
- // Nothing binds the resolver's configuration.
215
+ // Nothing binds the resolver's configuration, on either platform:
216
+ // bubblewrap by absence, sandbox-exec by `deny default` (EPERM).
172
217
  expect(reach.resolv_conf.succeeded).toBe(false);
173
218
 
174
219
  // ── Task 4.10 ──────────────────────────────────────────────────────
@@ -209,7 +254,12 @@ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.ba
209
254
  // edit that would flip it: someone reads the failures above, binds
210
255
  // `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden`
211
256
  // compares whole paths, so a bind of the DIRECTORY passes that filter.
212
- expect(reach.bwrap_present.succeeded).toBe(false);
257
+ // Asserted under bubblewrap only: there is no bubblewrap jail on macOS,
258
+ // so the row would measure whether the OPERATOR has bwrap installed,
259
+ // and posix_spawn of it is allowed anyway.
260
+ if (availability.backend !== 'sandbox-exec') {
261
+ expect(reach.bwrap_present.succeeded).toBe(false);
262
+ }
213
263
  } finally {
214
264
  cleanup();
215
265
  }
@@ -33,8 +33,9 @@ import { join, resolve } from 'node:path';
33
33
  /** Uniquely named so it never collides with a real key on a jailed dev box. */
34
34
  const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
35
35
  import { executeHookScript } from './executor';
36
- import { detectJailBackend } from './jail';
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');
@@ -45,7 +46,12 @@ interface Probe {
45
46
  }
46
47
 
47
48
  const availability = detectJailBackend();
48
- const jailed = availability.backend !== 'none';
49
+ // A host with a backend is not a host that JAILS: `CELILO_HOOK_JAIL=off` is an
50
+ // operator switch and the executor honours it, so without this the suite runs
51
+ // its assertions against a deliberately unjailed hook and reports the jail
52
+ // broken. Only reachable since macOS gained a backend (task 4.8) — before that
53
+ // every Mac skipped for want of one and the hole never showed.
54
+ const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
49
55
 
50
56
  interface Rig {
51
57
  outputs: {
@@ -105,12 +111,12 @@ async function runProbe(): Promise<Rig> {
105
111
 
106
112
  const { logger } = createCapturingLogger();
107
113
  const context: HookContext = {
108
- config: {
114
+ config: configStore({
109
115
  planted_secret: plantedSecret,
110
116
  sibling_file: siblingFile,
111
117
  staged_input: stagedInput,
112
- },
113
- secrets: {},
118
+ }),
119
+ secrets: secretStore(),
114
120
  systems: [],
115
121
  logger,
116
122
  debug: false,
@@ -119,26 +125,38 @@ async function runProbe(): Promise<Rig> {
119
125
  capabilities: {},
120
126
  };
121
127
 
122
- const outputs = (await executeHookScript(PROBE_HOOK, context, {
123
- timeoutMs: 60_000,
124
- idleTimeoutMs: 60_000,
125
- jail: {
126
- modulePath,
127
- pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
128
- },
129
- })) as unknown as Rig['outputs'];
130
-
131
- return {
132
- outputs,
133
- stagedInput,
134
- siblingFile,
135
- cleanup: () => {
136
- rmSync(scratch, { recursive: true, force: true });
137
- rmSync(stagedInput, { recursive: true, force: true });
138
- rmSync(plantedKey, { force: true });
139
- if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
140
- },
141
- };
128
+ // The jailed run jails under `required`: ce-29z made `auto` defer on
129
+ // sandbox-exec until D14 exists, so this suite's jail is the operator's
130
+ // explicit act — the bypass the deferral deliberately leaves open.
131
+ const savedPolicy = process.env.CELILO_HOOK_JAIL;
132
+ process.env.CELILO_HOOK_JAIL = 'required';
133
+ try {
134
+ const outputs = (await executeHookScript(PROBE_HOOK, context, {
135
+ timeoutMs: 60_000,
136
+ idleTimeoutMs: 60_000,
137
+ jail: {
138
+ modulePath,
139
+ pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
140
+ },
141
+ })) as unknown as Rig['outputs'];
142
+ return {
143
+ outputs,
144
+ stagedInput,
145
+ siblingFile,
146
+ cleanup: () => {
147
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
148
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
149
+ rmSync(scratch, { recursive: true, force: true });
150
+ rmSync(stagedInput, { recursive: true, force: true });
151
+ rmSync(plantedKey, { force: true });
152
+ if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
153
+ },
154
+ };
155
+ } catch (error) {
156
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
157
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
158
+ throw error;
159
+ }
142
160
  }
143
161
 
144
162
  describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => {
@@ -187,13 +205,21 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
187
205
  }, 90_000);
188
206
  });
189
207
 
190
- describe.skipIf(jailed)('no jail on this host', () => {
208
+ describe.skipIf(jailed)('this run is not jailed', () => {
191
209
  test('says so, rather than reporting a pass it did not earn', () => {
192
210
  // Not an assertion about the product. It is the line that stops a green
193
- // run on a Mac reading as "the jail was proven".
211
+ // run on an unjailed host reading as "the jail was proven".
212
+ const why =
213
+ availability.backend === 'none'
214
+ ? (availability.reason ?? 'no backend')
215
+ : `CELILO_HOOK_JAIL=${process.env.CELILO_HOOK_JAIL}, so the operator switched the ${availability.backend} jail off here`;
194
216
  console.log(
195
- `\nhook jail: SKIPPED the live suite — ${availability.reason ?? 'no backend'}\nThese properties are proven on Linux with bubblewrap. The hermetic half runs everywhere (jail.test.ts).`,
217
+ `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a backend is available and enabled. The hermetic half runs everywhere (jail.test.ts).`,
196
218
  );
197
- expect(availability.backend).toBe('none');
219
+ // Both reasons are legitimate and they are different facts, so assert the
220
+ // disjunction rather than one of them. Asserting `backend === 'none'` alone
221
+ // went red the day macOS got a backend AND the operator turned it off — a
222
+ // combination that is not a defect in anything.
223
+ expect(availability.backend === 'none' || jailPolicy() === 'off').toBe(true);
198
224
  });
199
225
  });
@@ -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;