@celilo/cli 2.2.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 (115) hide show
  1. package/package.json +2 -2
  2. package/schemas/system_config.json +5 -0
  3. package/src/ansible/inventory.test.ts +2 -1
  4. package/src/api/sessions.test.ts +2 -1
  5. package/src/cli/backup-rename.test.ts +2 -1
  6. package/src/cli/cli.test.ts +2 -1
  7. package/src/cli/commands/console-get-chain.test.ts +2 -1
  8. package/src/cli/commands/firewall-interface-list.test.ts +2 -1
  9. package/src/cli/commands/module-config.test.ts +2 -1
  10. package/src/cli/commands/module-deploy.ts +8 -2
  11. package/src/cli/commands/module-generate.test.ts +53 -0
  12. package/src/cli/commands/module-generate.ts +31 -26
  13. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  14. package/src/cli/commands/module-import-registry.test.ts +2 -1
  15. package/src/cli/commands/module-operations.test.ts +2 -1
  16. package/src/cli/commands/module-publish.test.ts +5 -12
  17. package/src/cli/commands/module-update.test.ts +6 -5
  18. package/src/cli/commands/module-upgrade.test.ts +15 -0
  19. package/src/cli/commands/module-upgrade.ts +54 -2
  20. package/src/cli/commands/module-verify.test.ts +2 -3
  21. package/src/cli/commands/module-verify.ts +0 -1
  22. package/src/cli/commands/notify-config.test.ts +5 -3
  23. package/src/cli/commands/registry-owner.test.ts +2 -1
  24. package/src/cli/commands/registry-token.test.ts +2 -1
  25. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  26. package/src/cli/commands/system-config.test.ts +148 -0
  27. package/src/cli/commands/system-config.ts +26 -1
  28. package/src/cli/commands/system-doctor.test.ts +71 -0
  29. package/src/cli/commands/system-doctor.ts +110 -24
  30. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  31. package/src/cli/commands/system-migrate.test.ts +2 -1
  32. package/src/cli/index.ts +2 -1
  33. package/src/cli/restore-command.test.ts +2 -1
  34. package/src/cli/restore-migration-failure.test.ts +2 -1
  35. package/src/config/paths.test.ts +3 -3
  36. package/src/db/migrate.test.ts +2 -1
  37. package/src/db/migrate.ts +16 -16
  38. package/src/hooks/capability-loader.test.ts +78 -0
  39. package/src/hooks/capability-loader.ts +37 -2
  40. package/src/hooks/executor.test.ts +87 -1
  41. package/src/hooks/executor.ts +57 -21
  42. package/src/hooks/hook-store.test.ts +6 -0
  43. package/src/hooks/hook-trespass.test.ts +17 -7
  44. package/src/hooks/jail.test.ts +61 -2
  45. package/src/hooks/jail.ts +53 -1
  46. package/src/module/packaging/audit.ts +9 -26
  47. package/src/module/packaging/build-paths.test.ts +127 -0
  48. package/src/module/packaging/build-paths.ts +175 -0
  49. package/src/module/packaging/build.test.ts +71 -1
  50. package/src/module/packaging/build.ts +60 -0
  51. package/src/module/packaging/extract.ts +1 -5
  52. package/src/policy/module-script-scan.ts +0 -40
  53. package/src/policy/no-swallowed-refusal.test.ts +9 -10
  54. package/src/services/alerting/ack.test.ts +2 -1
  55. package/src/services/alerting/cadence-migration.test.ts +3 -2
  56. package/src/services/alerting/coverage-source.test.ts +2 -1
  57. package/src/services/alerting/deferral.test.ts +2 -1
  58. package/src/services/alerting/delivery-loop.test.ts +2 -1
  59. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  60. package/src/services/alerting/inbound-poller.test.ts +2 -1
  61. package/src/services/alerting/inbound.test.ts +2 -1
  62. package/src/services/alerting/notification-responder.test.ts +2 -1
  63. package/src/services/alerting/run-monitor.test.ts +2 -1
  64. package/src/services/alerting/store.test.ts +2 -1
  65. package/src/services/alerting/sweep-runner.test.ts +2 -1
  66. package/src/services/alerting/tokens.test.ts +2 -1
  67. package/src/services/aspect-approvals.test.ts +2 -1
  68. package/src/services/aspect-reconcile.test.ts +4 -3
  69. package/src/services/aspect-runner.test.ts +2 -1
  70. package/src/services/audit/module-integrity.test.ts +0 -21
  71. package/src/services/audit/module-integrity.ts +0 -14
  72. package/src/services/backup-age-agreement.test.ts +2 -1
  73. package/src/services/backup-envelope-roundtrip.test.ts +2 -1
  74. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  75. package/src/services/bus-ensure-flow.test.ts +2 -1
  76. package/src/services/bus-interview-park.test.ts +2 -1
  77. package/src/services/bus-secret-flow.test.ts +2 -1
  78. package/src/services/capability-table-rows.test.ts +2 -1
  79. package/src/services/consumer-cleanup.test.ts +3 -2
  80. package/src/services/container-service.test.ts +2 -1
  81. package/src/services/control-plane-bootstrap.test.ts +2 -1
  82. package/src/services/cross-module-read.test.ts +2 -1
  83. package/src/services/deploy-validation.test.ts +3 -2
  84. package/src/services/dns-internal-records.test.ts +3 -2
  85. package/src/services/dns-provider-backfill.test.ts +2 -1
  86. package/src/services/dns-registrations.test.ts +2 -1
  87. package/src/services/ensure-interview.test.ts +3 -2
  88. package/src/services/fleet-checks.test.ts +3 -2
  89. package/src/services/fleet-key.test.ts +3 -2
  90. package/src/services/infrastructure-selector.test.ts +2 -1
  91. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  92. package/src/services/machine-pool.test.ts +2 -1
  93. package/src/services/module-config.test.ts +2 -1
  94. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  95. package/src/services/module-deploy.ts +36 -20
  96. package/src/services/module-operations.test.ts +2 -1
  97. package/src/services/module-subscriptions.test.ts +2 -1
  98. package/src/services/port-forwards.test.ts +2 -1
  99. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  100. package/src/services/proxmox-reconcile.test.ts +2 -1
  101. package/src/services/restore-from-file.test.ts +3 -2
  102. package/src/services/restore-preflight.test.ts +2 -1
  103. package/src/services/secret-schema-loader.test.ts +2 -1
  104. package/src/services/ssh-key-manager.test.ts +2 -1
  105. package/src/services/static-content-converge.test.ts +5 -4
  106. package/src/services/static-content-converge.ts +33 -10
  107. package/src/services/system-state-stage.test.ts +2 -1
  108. package/src/services/trusted-sources.test.ts +3 -2
  109. package/src/services/zone-detector.test.ts +2 -1
  110. package/src/test-utils/bus-responder.ts +5 -3
  111. package/src/test-utils/db-path.ts +25 -0
  112. package/src/test-utils/integration.ts +7 -0
  113. package/src/test-utils/module-fixtures.ts +5 -6
  114. package/src/module/packaging/generated-plane.test.ts +0 -79
  115. package/src/module/packaging/generated-plane.ts +0 -134
@@ -141,7 +141,7 @@ describe('paths configuration', () => {
141
141
  it.skipIf(skipIntegration({ platform: 'darwin' }))(
142
142
  'returns data dir + celilo.db on macOS in production',
143
143
  () => {
144
- process.env.CELILO_DB_PATH = undefined;
144
+ delete process.env.CELILO_DB_PATH;
145
145
  process.env.CELILO_DATA_DIR = undefined;
146
146
  process.env.ENVIRONMENT = undefined;
147
147
 
@@ -156,7 +156,7 @@ describe('paths configuration', () => {
156
156
  );
157
157
 
158
158
  it('returns celilo-data/celilo.db in development mode', () => {
159
- process.env.CELILO_DB_PATH = undefined;
159
+ delete process.env.CELILO_DB_PATH;
160
160
  process.env.CELILO_DATA_DIR = undefined;
161
161
  process.env.ENVIRONMENT = 'dev';
162
162
 
@@ -165,7 +165,7 @@ describe('paths configuration', () => {
165
165
  });
166
166
 
167
167
  it('respects CELILO_DATA_DIR override', () => {
168
- process.env.CELILO_DB_PATH = undefined;
168
+ delete process.env.CELILO_DB_PATH;
169
169
  process.env.CELILO_DATA_DIR = '/custom/data';
170
170
  process.env.ENVIRONMENT = undefined;
171
171
 
@@ -20,6 +20,7 @@ import { describe, expect, it } from 'bun:test';
20
20
  import { mkdtempSync, rmSync } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { join } from 'node:path';
23
+ import { resetTestDbPath } from '../test-utils/db-path';
23
24
  import { closeDb, getDb } from './client';
24
25
  import { runMigrations } from './migrate';
25
26
  import { systemConfig } from './schema';
@@ -48,7 +49,7 @@ describe('runMigrations connection lifecycle', () => {
48
49
  closeDb();
49
50
  } finally {
50
51
  closeDb();
51
- process.env.CELILO_DB_PATH = undefined;
52
+ resetTestDbPath();
52
53
  rmSync(dir, { recursive: true, force: true });
53
54
  }
54
55
  });
package/src/db/migrate.ts CHANGED
@@ -80,26 +80,26 @@ export function runMigrationsOn(db: DbClient): void {
80
80
  }
81
81
 
82
82
  /**
83
- * Run database migrations (standalone entrypoint — `bun run src/db/migrate.ts`).
84
- * createDbClient already migrates on open; this re-asserts for explicit use.
83
+ * Open a database at `dbPath` with migrations applied, then close it.
84
+ * Standalone entrypoint (`bun run src/db/migrate.ts`); tests also use it to
85
+ * prepare a fresh scratch database.
86
+ *
87
+ * createDbClient migrates synchronously on open (its own runMigrationsOn
88
+ * call), so this function is open + close. It deliberately does NOT call
89
+ * runMigrationsOn again: a second pass is always a ledger no-op when the
90
+ * first succeeded, and keeping it invited a double-migrate everywhere this
91
+ * entrypoint is used (celilo#1315).
85
92
  */
86
93
  export async function runMigrations(dbPath?: string) {
87
94
  console.log('Running database migrations...');
88
95
  const db = createDbClient(dbPath ? { path: dbPath } : undefined);
89
- try {
90
- runMigrationsOn(db);
91
- console.log('Migrations completed successfully');
92
- } catch (error) {
93
- console.error('Migration failed:', error);
94
- throw error;
95
- } finally {
96
- // Close the connection THIS call created. It is not the singleton —
97
- // createDbClient does not register one — so the old closeDb() here closed
98
- // whatever else was open (usually nothing) and leaked this connection,
99
- // which then held the file in WAL and blocked every later journal-mode
100
- // switch on it (celilo#1269).
101
- db.$client.close();
102
- }
96
+ console.log('Migrations completed successfully');
97
+ // Close the connection THIS call created. It is not the singleton —
98
+ // createDbClient does not register one — so an old closeDb() here closed
99
+ // whatever else was open (usually nothing) and leaked this connection,
100
+ // which then held the file in WAL and blocked every later journal-mode
101
+ // switch on it (celilo#1269).
102
+ db.$client.close();
103
103
  }
104
104
 
105
105
  // Run migrations if executed directly
@@ -7,7 +7,9 @@ import { mkdirSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import type { HookLogger, RouteReadView } from '@celilo/capabilities';
10
+ import { and, eq } from 'drizzle-orm';
10
11
  import type { DbClient } from '../db/client';
12
+ import { moduleConfigs } from '../db/schema';
11
13
  import { upsertModuleConfig } from '../services/module-config';
12
14
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
13
15
  import { loadCapabilityFunctions } from './capability-loader';
@@ -40,6 +42,36 @@ export default function createDhcpServer(context) {
40
42
  }
41
43
  `;
42
44
 
45
+ /**
46
+ * A factory that WRITES through the hook-owned-state accessor, so the loader's
47
+ * store view is proven end to end: the method's write lands as a hook-owned
48
+ * row, and an undeclared name throws instead of landing. Branded
49
+ * (defineCapabilityFunction, the Phase 8 pattern) — the branded path is the
50
+ * one the loader hands the store views to. The temp dir the fixture is
51
+ * dropped into has no node_modules link, so the import uses the workspace
52
+ * package by absolute path, the same trick capability-loader-firewall uses.
53
+ */
54
+ const TEST_STORE_WRITING_DHCP_MODULE = `
55
+ import { defineCapabilityFunction } from '${join(__dirname, '../../../..', 'packages/capabilities/src/index.ts')}';
56
+
57
+ export default defineCapabilityFunction({
58
+ capability: 'dhcp_server',
59
+ handler: (context) => ({
60
+ async setDnsServers() {},
61
+ async getDnsServers() { return [context.config.marker]; },
62
+ async setDomainName() {},
63
+ async getDomainName() { return context.config.marker; },
64
+ async claimIp() {
65
+ await context.config.set('public_ip', '203.0.113.7');
66
+ return 'claimed';
67
+ },
68
+ async claimUndeclared() {
69
+ await context.config.set('no_such_key', 'x');
70
+ },
71
+ }),
72
+ });
73
+ `;
74
+
43
75
  describe('Capability Loader', () => {
44
76
  let db: DbClient;
45
77
  let tempDir: string;
@@ -134,6 +166,52 @@ describe('Capability Loader', () => {
134
166
  expect(await dhcp.getDomainName()).toBe('internal-dhcp');
135
167
  });
136
168
 
169
+ test('a provider factory writes hook-owned config through the injected store view', async () => {
170
+ // The store view must do more than carry the plain record a factory reads.
171
+ // A method writing `context.config.set` lands a hook-owned row (validated
172
+ // against the PROVIDER's manifest, source column 'hook'), and an
173
+ // undeclared name throws instead of writing a phantom row.
174
+ const modulePath = join(tempDir, 'storewriting-vpn');
175
+ const scriptsDir = join(modulePath, 'scripts');
176
+ mkdirSync(scriptsDir, { recursive: true });
177
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_STORE_WRITING_DHCP_MODULE);
178
+ const manifest = JSON.stringify({
179
+ variables: { owns: [{ name: 'public_ip', type: 'string', source: 'hook' }] },
180
+ });
181
+ db.$client.run(
182
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('storewriting-vpn', 'StoreWritingVpn', '1.0.0', '${modulePath}', '${manifest.replace(/'/g, "''")}')`,
183
+ );
184
+ db.$client.run(
185
+ `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('storewriting-vpn', 'dhcp_server', '1.0.0', '{}', unixepoch())`,
186
+ );
187
+ upsertModuleConfig(db, 'storewriting-vpn', 'marker', 'storewriting-vpn');
188
+
189
+ const result = await loadCapabilityFunctions('store-consumer', db, noopLogger);
190
+ const dhcp = result.dhcp_server as {
191
+ claimIp(): Promise<string>;
192
+ claimUndeclared(): Promise<void>;
193
+ };
194
+
195
+ expect(await dhcp.claimIp()).toBe('claimed');
196
+ const row = db
197
+ .select()
198
+ .from(moduleConfigs)
199
+ .where(
200
+ and(eq(moduleConfigs.moduleId, 'storewriting-vpn'), eq(moduleConfigs.key, 'public_ip')),
201
+ )
202
+ .get();
203
+ expect(row?.valueJson).toBe('"203.0.113.7"');
204
+ expect(row?.source).toBe('hook');
205
+
206
+ // The store round-trips text, so the map read the NEXT load takes sees the
207
+ // same string the write sent.
208
+ const reloaded = await loadCapabilityFunctions('store-consumer', db, noopLogger);
209
+ const dhcpAgain = reloaded.dhcp_server as { getDnsServers(): Promise<string[]> };
210
+ expect(await dhcpAgain.getDnsServers()).toEqual(['storewriting-vpn']);
211
+
212
+ await expect(dhcp.claimUndeclared()).rejects.toThrow(/no declared hook-owned config/);
213
+ });
214
+
137
215
  test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
138
216
  const modulePath = join(tempDir, 'upstream-dhcp');
139
217
  const scriptsDir = join(modulePath, 'scripts');
@@ -60,6 +60,9 @@ import {
60
60
  import { resolveComputedFields } from '../variables/computed/evaluate';
61
61
  import { containsComputedMarker } from '../variables/computed/marker';
62
62
  import { buildProviderLookup } from '../variables/computed/provider-lookup';
63
+ import type { BufferedStoreOp } from './hook-protocol';
64
+ import { type HookStores, createHookStores } from './hook-store';
65
+ import { type StoreTransport, buildStoreView } from './hook-store-proxy';
63
66
  import { loadHookConfigMap } from './load-hook-config';
64
67
 
65
68
  /**
@@ -260,6 +263,13 @@ export async function loadCapabilityFunctions(
260
263
  // This allows modules to optionally consume capabilities at runtime
261
264
  // (e.g., caddy uses dns_registrar if available, falls back if not).
262
265
  const verbose = process.env.CELILO_DEBUG === '1';
266
+
267
+ // The broker-side stores, resolved from the PROVIDER on the first store
268
+ // call this consumer load makes, and shared across every capability it
269
+ // builds. Nothing pays for the manifest read until a factory actually
270
+ // writes or reads through the accessor. See the storeTransport construction
271
+ // at the factory-invocation site below.
272
+ let storesPromise: Promise<HookStores> | undefined;
263
273
  const debugLog = verbose
264
274
  ? (msg: string) => process.stderr.write(`[capability-loader] ${msg}\n`)
265
275
  : (_msg: string) => {};
@@ -392,10 +402,35 @@ export async function loadCapabilityFunctions(
392
402
  // Phase 8 path: branded defineCapabilityFunction factory. Call with
393
403
  // the canonical { config, secrets, logger } context. The factory
394
404
  // applies wrapWithLogging internally so we don't double-wrap.
405
+ //
406
+ // Both maps arrive as the hook-owned-state view (buildStoreView): the
407
+ // plain record the factory reads, PLUS get/set/delete/transaction that
408
+ // reach the broker-side stores (createHookStores), validated against
409
+ // the PROVIDER module's own manifest. The transport is in-process — a
410
+ // capability factory runs here in celilo's process, not in the jail —
411
+ // but the view is the same object a jailed hook sees, so a provider's
412
+ // self-config write goes through the same validated door regardless of
413
+ // which process it runs in (hook-owned-state tasks 0.7 and 5.5 shape).
395
414
  if (isCompiledCapabilityFactory(exported)) {
415
+ const storeTransport: StoreTransport = (store, method, args) => {
416
+ storesPromise ??= createHookStores(db, capability.moduleId);
417
+ return storesPromise.then((backend: HookStores): Promise<unknown> => {
418
+ const one = backend[store];
419
+ switch (method) {
420
+ case 'transaction':
421
+ return one.applyTransaction(args[0] as readonly BufferedStoreOp[]);
422
+ case 'set':
423
+ return one.set(args[0] as string, args[1] as string);
424
+ case 'delete':
425
+ return one.delete(args[0] as string);
426
+ default:
427
+ return one.get(args[0] as string);
428
+ }
429
+ });
430
+ };
396
431
  const capabilityInterface = exported({
397
- config: providerConfig,
398
- secrets: providerSecrets,
432
+ config: buildStoreView('config', providerConfig, storeTransport),
433
+ secrets: buildStoreView('secrets', providerSecrets, storeTransport),
399
434
  systems: getModuleSystems(capability.moduleId, db),
400
435
  logger,
401
436
  // WHO IS CALLING, so a provider can scope per-consumer state to them.
@@ -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,
@@ -329,6 +333,88 @@ describe('Hook Executor', () => {
329
333
  });
330
334
  });
331
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
+
332
418
  describe('resolveHookTimeouts', () => {
333
419
  test('no declaration: 60s total, 30s idle heuristic', () => {
334
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,
@@ -73,10 +75,10 @@ import {
73
75
  import {
74
76
  type JailPlan,
75
77
  detectJailBackend,
76
- jailPolicy,
77
78
  planJailedSpawn,
78
79
  realpathRequest,
79
80
  recordJailMode,
81
+ resolveJailPolicy,
80
82
  runtimeModulePathsFor,
81
83
  } from './jail';
82
84
  import {
@@ -171,32 +173,47 @@ const FORWARDED_ENV = [
171
173
  ] as const;
172
174
 
173
175
  /**
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
+ * 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.
176
189
  *
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.
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).
187
201
  *
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.
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.
191
206
  *
192
207
  * Exported for the allow-list tests (hook-trespass.test.ts), which pin this
193
208
  * contract the same way they pin the rest of the child environment.
194
209
  */
195
210
  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}`;
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(':');
200
217
  }
201
218
 
202
219
  /** The shim celilo spawns. Resolved from here so an npm install finds it too. */
@@ -330,6 +347,25 @@ export interface ExecuteHookOptions {
330
347
  hookStores?: HookStoresProvider;
331
348
  }
332
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;
367
+ }
368
+
333
369
  /**
334
370
  * Execute a hook script
335
371
  *
@@ -394,7 +430,7 @@ export async function executeHookScript(
394
430
  [process.execPath, HOOK_RUNNER_SPAWN_PATH],
395
431
  mountSet,
396
432
  detectJailBackend(),
397
- jailPolicy(),
433
+ resolveJailPolicy(process.env.CELILO_HOOK_JAIL, readStoredJailPolicy()).policy,
398
434
  );
399
435
  reportJail(jail, logger);
400
436
  // Only a real module hook says anything about whether this HOST jails. An
@@ -14,6 +14,7 @@ import { join } from 'node:path';
14
14
  import { type DbClient, closeDb, createDbClient } from '../db/client';
15
15
  import { runMigrations } from '../db/migrate';
16
16
  import { moduleConfigs, modules, secrets } from '../db/schema';
17
+ import { resetTestDbPath } from '../test-utils/db-path';
17
18
  import { createHookStores } from './hook-store';
18
19
 
19
20
  describe('hook stores', () => {
@@ -54,6 +55,11 @@ describe('hook stores', () => {
54
55
 
55
56
  afterEach(() => {
56
57
  closeDb();
58
+ // The variable pointed at this suite's (now removed) temp database;
59
+ // leaving it set sends the next var-less reader at a dangling path.
60
+ // Reset to the scratch path so no later reader reaches the operator's
61
+ // real celilo.db (celilo#1315).
62
+ resetTestDbPath();
57
63
  rmSync(testDir, { recursive: true, force: true });
58
64
  delete process.env.CELILO_MASTER_KEY_PATH;
59
65
  });
@@ -31,7 +31,7 @@ import {
31
31
  listUnpublishedWorkspacePackages,
32
32
  planFallbacks,
33
33
  } from '../../../../scripts/workspace-fallback';
34
- import { childPath, executeHookScript, hookChildEnv } from './executor';
34
+ import { CLI_BIN_DIR, childPath, executeHookScript, hookChildEnv } from './executor';
35
35
  import { HOOK_PROTOCOL_VERSION } from './hook-protocol';
36
36
  import { detectJailBackend, jailPolicy } from './jail';
37
37
  import { createCapturingLogger } from './logger';
@@ -246,19 +246,29 @@ describe('the child environment is an allow-list', () => {
246
246
  // celilo#1300: a hook that spawns `celilo` by name must find the same CLI
247
247
  // the parent runs, whatever the inherited PATH looks like.
248
248
  describe('childPath', () => {
249
- test('prepends the runtime directory so the child reaches the parent CLI', () => {
249
+ test('prepends the CLI bin and runtime directories so the child reaches the parent CLI', () => {
250
250
  const child = childPath('/usr/local/bin:/usr/bin');
251
- expect(child.startsWith(`${dirname(process.execPath)}:`)).toBe(true);
251
+ expect(child.startsWith(`${CLI_BIN_DIR}:${dirname(process.execPath)}:`)).toBe(true);
252
252
  expect(child.endsWith('/usr/local/bin:/usr/bin')).toBe(true);
253
253
  });
254
254
 
255
- test('leaves an inherited PATH that already carries the directory unchanged', () => {
256
- const already = `${dirname(process.execPath)}:/usr/bin`;
255
+ test('leaves an inherited PATH that already carries the directories unchanged', () => {
256
+ const already = `${CLI_BIN_DIR}:${dirname(process.execPath)}:/usr/bin`;
257
257
  expect(childPath(already)).toBe(already);
258
258
  });
259
259
 
260
- test('substitutes the directory when the parent has no PATH at all', () => {
261
- expect(childPath(undefined)).toBe(dirname(process.execPath));
260
+ test('substitutes both directories when the parent has no PATH at all', () => {
261
+ expect(childPath(undefined)).toBe(`${CLI_BIN_DIR}:${dirname(process.execPath)}`);
262
+ });
263
+
264
+ // The fleet gate (ce-y0fd): the .deb layout runs the CLI through
265
+ // $CELILO_HOME/node_modules/@celilo/cli/bin/celilo, so the directory beside
266
+ // process.execPath holds only bun and holds no celilo. A PATH that resolves
267
+ // `celilo` must therefore come from the CLI package's own bin directory —
268
+ // and that directory must really contain the bin, or every prepended PATH
269
+ // entry is decoration over the same accident celilo#1300 recorded.
270
+ test('prepends a CLI bin directory that actually contains the celilo bin', () => {
271
+ expect(existsSync(join(CLI_BIN_DIR, 'celilo'))).toBe(true);
262
272
  });
263
273
  });
264
274
  });
@@ -27,6 +27,7 @@ import {
27
27
  readJailMode,
28
28
  realpathRequest,
29
29
  recordJailMode,
30
+ resolveJailPolicy,
30
31
  runtimeModulePathsFor,
31
32
  } from './jail';
32
33
  import { deriveMountSet } from './mount-set';
@@ -272,8 +273,12 @@ describe('jailPolicy reads the operator’s switch', () => {
272
273
  }
273
274
  };
274
275
 
275
- test('unset is auto', () => {
276
- expect(withEnv(undefined, jailPolicy)).toBe('auto');
276
+ test('unset is off (peba, ce-rez7)', () => {
277
+ expect(withEnv(undefined, jailPolicy)).toBe('off');
278
+ });
279
+
280
+ test('an explicit auto still means auto', () => {
281
+ expect(withEnv('auto', jailPolicy)).toBe('auto');
277
282
  });
278
283
 
279
284
  test('a typo is refused rather than silently meaning auto', () => {
@@ -284,6 +289,60 @@ describe('jailPolicy reads the operator’s switch', () => {
284
289
  });
285
290
  });
286
291
 
292
+ describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', () => {
293
+ test('an env value set and non-empty wins over stored config', () => {
294
+ expect(resolveJailPolicy('required', 'off')).toEqual({ policy: 'required', source: 'env' });
295
+ });
296
+
297
+ test('an EMPTY STRING env value counts as unset and falls through to config', () => {
298
+ // jailPolicy() already treats '' as unset, so the resolver agrees rather
299
+ // than inventing a second rule for what counts as set.
300
+ expect(resolveJailPolicy('', 'off')).toEqual({ policy: 'off', source: 'config' });
301
+ });
302
+
303
+ test('stored config wins when the env var is absent', () => {
304
+ expect(resolveJailPolicy(undefined, 'off')).toEqual({ policy: 'off', source: 'config' });
305
+ expect(resolveJailPolicy(undefined, 'required')).toEqual({
306
+ policy: 'required',
307
+ source: 'config',
308
+ });
309
+ });
310
+
311
+ test('both absent resolves to off from the default (peba, ce-rez7)', () => {
312
+ expect(resolveJailPolicy(undefined, undefined)).toEqual({ policy: 'off', source: 'default' });
313
+ expect(resolveJailPolicy('', undefined)).toEqual({ policy: 'off', source: 'default' });
314
+ });
315
+
316
+ test('an explicit env auto still wins (peba, ce-rez7)', () => {
317
+ expect(resolveJailPolicy('auto', 'off')).toEqual({ policy: 'auto', source: 'env' });
318
+ });
319
+
320
+ test('a stored empty jail_policy THROWS, it does not resolve to auto (peba, ce-8832)', () => {
321
+ // The set-time pattern ^(auto|off|required)$ rejects '', so a stored one
322
+ // can only arrive via a restore from file or a hand edit to the DB — the
323
+ // untrusted paths D3 exists to catch. It is outside the enum and fails
324
+ // closed like any other bad stored value.
325
+ expect(() => resolveJailPolicy(undefined, '')).toThrow(/is not a hook jail policy/);
326
+ expect(() => resolveJailPolicy(undefined, '')).toThrow(/hooks\.jail_policy=''/);
327
+ });
328
+
329
+ test('a typo in the STORED row is refused, not silently treated as auto (D3)', () => {
330
+ // The DB row is a trust boundary like any other (Rule 3.7): restores carry
331
+ // foreign state, and a typo that silently meant `auto` reads as an armed
332
+ // jail when nothing is armed. The config path is no laxer than the env
333
+ // path.
334
+ expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(/is not a hook jail policy/);
335
+ expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(
336
+ /hooks\.jail_policy='alwayssafe'/,
337
+ );
338
+ });
339
+
340
+ test('a typo in the env value is still refused (unchanged jailPolicy behaviour)', () => {
341
+ expect(() => resolveJailPolicy('requried', undefined)).toThrow(/is not a hook jail policy/);
342
+ expect(() => resolveJailPolicy('requried', 'off')).toThrow(/CELILO_HOOK_JAIL='requried'/);
343
+ });
344
+ });
345
+
287
346
  describe('realpath at the caller (task 4.2k)', () => {
288
347
  test('a symlinked module root is resolved before it becomes a bind', () => {
289
348
  // `mod.sourcePath` comes out of the database, and a database restored from