@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
@@ -17,6 +17,7 @@ import {
17
17
  runBuiltinCheckForMonitor,
18
18
  } from '../../services/alerting/builtin-source';
19
19
  import { loadModuleCoverage } from '../../services/alerting/coverage-source';
20
+ import { renderMonitorRunMessage } from '../../services/alerting/format';
20
21
  import { loadModuleHealthCadences } from '../../services/alerting/health-cadence';
21
22
  import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
22
23
  import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
@@ -198,16 +199,7 @@ async function handleRun(args: string[]): Promise<CommandResult> {
198
199
  const summary = await runOneMonitor(db, monitor, buildDeps());
199
200
  promoteReadyAlerts(db, new Date());
200
201
 
201
- if (summary.outcome === 'error') {
202
- return {
203
- success: true,
204
- message: `${target}: check could not run — ${summary.errorMessage ?? 'unknown error'}`,
205
- };
206
- }
207
- return {
208
- success: true,
209
- message: `${target}: ${summary.failingKeyCount} failing, ${summary.resolvedIds.length} resolved`,
210
- };
202
+ return { success: true, message: renderMonitorRunMessage(target, summary) };
211
203
  }
212
204
 
213
205
  function handleToggle(args: string[], enabled: boolean): CommandResult {
@@ -22,10 +22,10 @@ import { join } from 'node:path';
22
22
  import { closeDb, getDb } from '../../db/client';
23
23
  import { createPerson } from '../../services/alerting/people';
24
24
  import { setupTestDatabaseAt } from '../../test-utils/database';
25
+ import { resetTestDbPath } from '../../test-utils/db-path';
25
26
  import { handlePerson } from './notify-config';
26
27
 
27
28
  let dir: string;
28
- const previousDbPath = process.env.CELILO_DB_PATH;
29
29
 
30
30
  beforeEach(async () => {
31
31
  dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
@@ -39,8 +39,10 @@ beforeEach(async () => {
39
39
 
40
40
  afterEach(() => {
41
41
  closeDb();
42
- if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
43
- else process.env.CELILO_DB_PATH = previousDbPath;
42
+ // Reset to the scratch path, never restore: the saved value may be another
43
+ // suite's temp database or unset — which sends the next var-less reader to
44
+ // the operator's real celilo.db (celilo#1315).
45
+ resetTestDbPath();
44
46
  rmSync(dir, { recursive: true, force: true });
45
47
  });
46
48
 
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { closeDb, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
+ import { resetTestDbPath } from '../../test-utils/db-path';
14
15
  import {
15
16
  handleRegistryOwnerList,
16
17
  handleRegistryOwnerSet,
@@ -45,7 +46,7 @@ describe('registry owner commands', () => {
45
46
  fetchSpy.mockRestore();
46
47
  closeDb();
47
48
  process.env.CELILO_DATA_DIR = undefined;
48
- process.env.CELILO_DB_PATH = undefined;
49
+ resetTestDbPath();
49
50
  if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true });
50
51
  });
51
52
 
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import { closeDb, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
14
  import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import { handleRegistryTokenAdd, handleRegistryTokenRm } from './registry-token';
16
17
 
17
18
  /** Read back the stored publish_tokens list via the production read path. */
@@ -50,7 +51,7 @@ describe('registry token add/rm (F2 clobber fix)', () => {
50
51
  afterEach(async () => {
51
52
  closeDb();
52
53
  process.env.CELILO_DATA_DIR = undefined;
53
- process.env.CELILO_DB_PATH = undefined;
54
+ resetTestDbPath();
54
55
  if (existsSync(dataDir)) {
55
56
  await rm(dataDir, { recursive: true, force: true });
56
57
  }
@@ -82,15 +82,25 @@ export async function handleRestore(
82
82
 
83
83
  // Run Drizzle migrations on the restored DB so a backup from an
84
84
  // older celilo opens cleanly. No-op if the DB schema is already
85
- // current. Best-effort: if migrations fail (e.g., a destructive
86
- // backward migration would be needed), surface the error but the
87
- // restore itself already landed.
85
+ // current. A failure here FAILS the restore (celilo#1269): the swap
86
+ // has landed, so the box holds restored state it cannot yet open
87
+ // safely, and reporting success over an unmigrated database trains
88
+ // operators to ignore the one warning that matters on a recovery
89
+ // path. The error names the command that retries the migration and
90
+ // the check that says whether the schema is complete.
91
+
92
+ let migrationError: string | undefined;
88
93
  try {
89
94
  await migrateRestoredDb();
90
95
  } catch (err) {
91
- log.warn(
92
- `Restore complete but migrations failed: ${err instanceof Error ? err.message : String(err)}. The restored DB may need manual migration.`,
93
- );
96
+ migrationError = err instanceof Error ? err.message : String(err);
97
+ }
98
+
99
+ if (migrationError) {
100
+ return {
101
+ success: false,
102
+ error: `Restore landed but migrations failed: ${migrationError}. The database was restored to this box, but its schema may not match this celilo build and celilo commands may fail against it. Run \`celilo system migrate\` to retry the migrations, and \`celilo system doctor\` to check whether the schema is complete before using the box.`,
103
+ };
94
104
  }
95
105
 
96
106
  // Re-register event-bus subscriptions from the restored modules' manifests.
@@ -30,24 +30,22 @@ import { tmpdir } from 'node:os';
30
30
  import { join } from 'node:path';
31
31
  import { getDb } from '../../db/client';
32
32
  import { loadExistingConfiguration } from '../../services/system-init';
33
+ import { resetTestDbPath } from '../../test-utils/db-path';
33
34
  import { handleSystemApplyConfig } from './system-apply-config';
34
35
  import { handleSystemInit } from './system-init';
35
36
 
36
37
  describe('system apply-config equivalence with system init --accept-defaults', () => {
37
38
  let tmpDir: string;
38
- let savedDbPath: string | undefined;
39
39
 
40
40
  beforeEach(() => {
41
41
  tmpDir = mkdtempSync(join(tmpdir(), 'celilo-apply-config-test-'));
42
- savedDbPath = process.env.CELILO_DB_PATH;
43
42
  });
44
43
 
45
44
  afterEach(() => {
46
- if (savedDbPath !== undefined) {
47
- process.env.CELILO_DB_PATH = savedDbPath;
48
- } else {
49
- process.env.CELILO_DB_PATH = undefined;
50
- }
45
+ // Reset to the scratch path, never restore: the saved value may be
46
+ // another suite's temp database, or unset — which sends the next
47
+ // var-less reader to the operator's real celilo.db (celilo#1315).
48
+ resetTestDbPath();
51
49
  rmSync(tmpDir, { recursive: true, force: true });
52
50
  });
53
51
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Tests for the set-time interview gate on `system config set`
3
+ * (slice 3 of hook-jail-config-surface, design D5 + peba's --force addition).
4
+ *
5
+ * Writing `hooks.jail_policy off` is a fleet-wide security posture change, so
6
+ * the write asks first over the bus interview and only proceeds on an explicit
7
+ * confirm. `auto` and `required` write directly. `--force` skips the ask per
8
+ * the house flags.force convention.
9
+ *
10
+ * Isolation: CELILO_DB_PATH / CELILO_DATA_DIR / EVENT_BUS_DB are set before the
11
+ * SUT is imported, so nothing touches the production database or event bus.
12
+ * bun test runs non-TTY, so `withInterviewSession` starts no terminal responder
13
+ * and the interview guard probes the isolated bus — a successful direct write
14
+ * with no responder running is itself proof the command never asked.
15
+ */
16
+
17
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { eq } from 'drizzle-orm';
22
+
23
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jailgate-'));
24
+ const busDbPath = join(testRoot, 'bus.db');
25
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
26
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
27
+ process.env.EVENT_BUS_DB = busDbPath;
28
+
29
+ const { getDb, closeDb } = await import('../../db/client');
30
+ const { systemConfig } = await import('../../db/schema');
31
+ const { handleSystemConfigSet } = await import('./system-config');
32
+ const { startProgrammaticResponder } = await import('../../services/programmatic-responder');
33
+
34
+ const KEY = 'hooks.jail_policy';
35
+ const CONFIRM_KEY = 'hooks-jail-policy.set-off';
36
+
37
+ function storedJailPolicy(): string | undefined {
38
+ return getDb().select().from(systemConfig).where(eq(systemConfig.key, KEY)).get()?.value;
39
+ }
40
+
41
+ afterAll(() => {
42
+ closeDb();
43
+ rmSync(testRoot, { recursive: true, force: true });
44
+ });
45
+
46
+ describe('system config set — jail_policy value validation', () => {
47
+ beforeEach(() => {
48
+ getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
49
+ });
50
+
51
+ test('a value outside the enum is refused and names the allowed values', async () => {
52
+ const result = await handleSystemConfigSet([KEY, 'alwayssafe']);
53
+
54
+ expect(result.success).toBe(false);
55
+ if (result.success) throw new Error('expected a refusal');
56
+ expect(result.error).toContain('auto');
57
+ expect(result.error).toContain('required');
58
+ expect(storedJailPolicy()).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe('system config set — jail_policy values that never ask', () => {
63
+ beforeEach(() => {
64
+ getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
65
+ });
66
+
67
+ test('auto writes directly with no responder listening', async () => {
68
+ const result = await handleSystemConfigSet([KEY, 'auto']);
69
+
70
+ expect(result.success).toBe(true);
71
+ expect(storedJailPolicy()).toBe('auto');
72
+ });
73
+
74
+ test('required writes directly with no responder listening', async () => {
75
+ const result = await handleSystemConfigSet([KEY, 'required']);
76
+
77
+ expect(result.success).toBe(true);
78
+ expect(storedJailPolicy()).toBe('required');
79
+ });
80
+ });
81
+
82
+ describe('system config set — the off interview gate', () => {
83
+ beforeEach(() => {
84
+ getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
85
+ });
86
+
87
+ test('off fails fast with no responder listening and writes nothing', async () => {
88
+ let threw: unknown;
89
+ try {
90
+ await handleSystemConfigSet([KEY, 'off']);
91
+ } catch (error) {
92
+ threw = error;
93
+ }
94
+
95
+ expect(threw).toBeInstanceOf(Error);
96
+ expect(String((threw as Error).message)).toContain('No responder is listening');
97
+ expect(storedJailPolicy()).toBeUndefined();
98
+ });
99
+
100
+ test('off writes after a confirming reply over non-TTY, and the question was asked', async () => {
101
+ const responder = startProgrammaticResponder({
102
+ busDbPath,
103
+ db: getDb(),
104
+ onMissing: 'skip',
105
+ values: { interview: { [CONFIRM_KEY]: true } },
106
+ });
107
+ try {
108
+ const result = await handleSystemConfigSet([KEY, 'off']);
109
+
110
+ expect(result.success).toBe(true);
111
+ expect(storedJailPolicy()).toBe('off');
112
+
113
+ const asked = responder.seenInterviewPayloads();
114
+ expect(asked.length).toBe(1);
115
+ expect(asked[0].scope).toBe('hooks-jail-policy');
116
+ expect(asked[0].key).toBe('set-off');
117
+ expect(asked[0].message).toContain('unjailed');
118
+ } finally {
119
+ responder.close();
120
+ }
121
+ });
122
+
123
+ test('off is cancelled when the reply declines, and writes nothing', async () => {
124
+ const responder = startProgrammaticResponder({
125
+ busDbPath,
126
+ db: getDb(),
127
+ onMissing: 'skip',
128
+ values: { interview: { [CONFIRM_KEY]: false } },
129
+ });
130
+ try {
131
+ const result = await handleSystemConfigSet([KEY, 'off']);
132
+
133
+ expect(result.success).toBe(false);
134
+ if (result.success) throw new Error('expected cancellation');
135
+ expect(result.error).toBe('Cancelled by user');
136
+ expect(storedJailPolicy()).toBeUndefined();
137
+ } finally {
138
+ responder.close();
139
+ }
140
+ });
141
+
142
+ test('--force skips the ask and writes off with no responder listening', async () => {
143
+ const result = await handleSystemConfigSet([KEY, 'off'], { force: true });
144
+
145
+ expect(result.success).toBe(true);
146
+ expect(storedJailPolicy()).toBe('off');
147
+ });
148
+ });
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
7
7
  import { eq } from 'drizzle-orm';
8
8
  import { getDb } from '../../db/client';
9
9
  import { systemConfig } from '../../db/schema';
10
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
10
11
  import type { SystemConfigSchema } from '../../services/system-config-schema-types';
11
12
  import { validateKey, validateValue } from '../../services/system-config-validator';
12
13
  import { getArg, validateRequiredArgs } from '../parser';
@@ -47,7 +48,10 @@ function loadSchema(): SystemConfigSchema {
47
48
  * @param args - Command arguments
48
49
  * @returns Command result
49
50
  */
50
- export function handleSystemConfigSet(args: string[]): CommandResult {
51
+ export async function handleSystemConfigSet(
52
+ args: string[],
53
+ flags: Record<string, boolean | string> = {},
54
+ ): Promise<CommandResult> {
51
55
  // Validate arguments
52
56
  const error = validateRequiredArgs(args, 2);
53
57
  if (error) {
@@ -82,6 +86,27 @@ export function handleSystemConfigSet(args: string[]): CommandResult {
82
86
  return { success: false, error: valueValidation.error };
83
87
  }
84
88
 
89
+ // D5 of hook-jail-config-surface: turning jailing off fleet-wide is a security
90
+ // posture change, so the write asks first. `auto` and `required` write
91
+ // directly — `auto` is the status quo default and `required` tightens the
92
+ // posture. `--force` skips the ask, matching the house flags.force convention
93
+ // (backup-delete, machine-remove, service-remove, ...).
94
+ if (key === 'hooks.jail_policy' && value === 'off' && !flags.force) {
95
+ const confirmed = await withInterviewSession(() =>
96
+ askConfirm({
97
+ scope: 'hooks-jail-policy',
98
+ key: 'set-off',
99
+ message:
100
+ 'Set hooks.jail_policy to off? Hooks will run unjailed fleet-wide and the doctor will warn.',
101
+ defaultValue: false,
102
+ }),
103
+ );
104
+
105
+ if (!confirmed) {
106
+ return { success: false, error: 'Cancelled by user' };
107
+ }
108
+ }
109
+
85
110
  const db = getDb();
86
111
 
87
112
  // Store value in database
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import type { JailModeRecord } from '../../hooks/jail';
3
3
  import {
4
4
  type HookExecutionInput,
5
+ assembleHookExecutionSection,
5
6
  compareVersions,
6
7
  renderHookExecutionSection,
7
8
  } from './system-doctor';
@@ -61,6 +62,9 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
61
62
  renderHookExecutionSection({
62
63
  availability: { backend: 'bubblewrap' },
63
64
  policy: 'auto',
65
+ source: 'default',
66
+ envValue: undefined,
67
+ configValue: undefined,
64
68
  record: jailedRecord,
65
69
  host: HOST,
66
70
  monitored: true,
@@ -153,4 +157,71 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
153
157
  expect(text(r)).toContain('belongs to host "old-box"');
154
158
  expect(r.failCount).toBe(0);
155
159
  });
160
+
161
+ test('the source line names the environment when the env var won (D4)', () => {
162
+ const r = render({
163
+ policy: 'off',
164
+ source: 'env',
165
+ envValue: 'off',
166
+ record: undefined,
167
+ });
168
+ expect(text(r)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
169
+ });
170
+
171
+ test('the source line names system config when the stored key won (D4)', () => {
172
+ const r = render({
173
+ policy: 'required',
174
+ source: 'config',
175
+ configValue: 'required',
176
+ record: undefined,
177
+ });
178
+ expect(text(r)).toContain('policy from system config (hooks.jail_policy)');
179
+ expect(text(r)).not.toContain('policy from CELILO_HOOK_JAIL');
180
+ });
181
+
182
+ test('the source line names the default and how to change it (D4)', () => {
183
+ const r = render({ policy: 'auto', source: 'default', record: undefined });
184
+ expect(text(r)).toContain(
185
+ 'policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change',
186
+ );
187
+ });
188
+
189
+ test('env and config disagreeing renders the losing value (D4)', () => {
190
+ const r = render({
191
+ policy: 'required',
192
+ source: 'env',
193
+ envValue: 'required',
194
+ configValue: 'off',
195
+ record: undefined,
196
+ });
197
+ expect(text(r)).toContain(
198
+ "system config holds 'off' but CELILO_HOOK_JAIL='required' overrides it",
199
+ );
200
+ });
201
+
202
+ test('env and config agreeing renders no disagreement line (D4)', () => {
203
+ const r = render({
204
+ policy: 'off',
205
+ source: 'env',
206
+ envValue: 'off',
207
+ configValue: 'off',
208
+ record: undefined,
209
+ });
210
+ expect(text(r)).not.toContain('system config holds');
211
+ });
212
+
213
+ test('a bad stored row renders as a failing check naming the row, not a crash', () => {
214
+ const r = assembleHookExecutionSection({
215
+ envValue: () => undefined,
216
+ configValue: () => 'alwayssafe',
217
+ availability: () => ({ backend: 'bubblewrap' }),
218
+ record: () => undefined,
219
+ host: () => HOST,
220
+ monitored: () => true,
221
+ });
222
+ expect(r.lines[0]).toBe('Hook execution');
223
+ expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
224
+ expect(r.failCount).toBe(1);
225
+ expect(r.warnCount).toBe(0);
226
+ });
156
227
  });
@@ -44,11 +44,12 @@ import {
44
44
  type JailAvailability,
45
45
  type JailModeRecord,
46
46
  type JailPolicy,
47
+ type JailPolicySource,
47
48
  SANDBOX_EXEC_AUTO_DEFERRED_REASON,
48
49
  autoJailDefers,
49
50
  detectJailBackend,
50
- jailPolicy,
51
51
  readJailMode,
52
+ resolveJailPolicy,
52
53
  } from '../../hooks/jail';
53
54
  import type { ModuleManifest } from '../../manifest/schema';
54
55
  import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
@@ -353,6 +354,12 @@ function provisionedBrowserFlavor(): string | null {
353
354
  export interface HookExecutionInput {
354
355
  availability: JailAvailability;
355
356
  policy: JailPolicy;
357
+ /** Which source decided `policy` (D4 of hook-jail-config-surface). */
358
+ source: JailPolicySource;
359
+ /** Raw `CELILO_HOOK_JAIL` value, when set; rendered on the source line. */
360
+ envValue: string | undefined;
361
+ /** Raw stored `hooks.jail_policy` value, when present; rendered on the disagreement line. */
362
+ configValue: string | undefined;
356
363
  /** What the last real module-hook run recorded, if any. */
357
364
  record: JailModeRecord | undefined;
358
365
  host: string;
@@ -390,6 +397,31 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
390
397
  let failCount = 0;
391
398
  let warnCount = 0;
392
399
 
400
+ // Which source decided the policy (D4 of hook-jail-config-surface). When
401
+ // env and config both hold values and disagree, name the loser too: a
402
+ // control whose two sources disagree and whose rendering hides one of them
403
+ // is the exact failure this change fixes, one level down.
404
+ if (input.source === 'env' && input.envValue !== undefined) {
405
+ lines.push(
406
+ ` ${ANSI.dim}policy from CELILO_HOOK_JAIL=${input.envValue} (environment override)${ANSI.reset}`,
407
+ );
408
+ } else if (input.source === 'config') {
409
+ lines.push(` ${ANSI.dim}policy from system config (hooks.jail_policy)${ANSI.reset}`);
410
+ } else {
411
+ lines.push(
412
+ ` ${ANSI.dim}policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
413
+ );
414
+ }
415
+ if (
416
+ input.source === 'env' &&
417
+ input.configValue !== undefined &&
418
+ input.envValue !== input.configValue
419
+ ) {
420
+ lines.push(
421
+ ` ${ANSI.dim}system config holds '${input.configValue}' but CELILO_HOOK_JAIL='${input.envValue}' overrides it${ANSI.reset}`,
422
+ );
423
+ }
424
+
393
425
  // ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
394
426
  // the executor will not build. One predicate, two consumers.
395
427
  const autoDeferred = policy === 'auto' && autoJailDefers(availability);
@@ -456,6 +488,71 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
456
488
  return { lines, failCount, warnCount };
457
489
  }
458
490
 
491
+ /** Where `assembleHookExecutionSection` gets what the machine says. Every
492
+ * reader is a thunk so any of their throws (a bad stored row, a corrupt
493
+ * record) land in the one catch that renders this section. */
494
+ export interface HookExecutionIO {
495
+ envValue: () => string | undefined;
496
+ configValue: () => string | undefined;
497
+ availability: () => JailAvailability;
498
+ record: () => JailModeRecord | undefined;
499
+ host: () => string;
500
+ monitored: () => boolean | null;
501
+ }
502
+
503
+ /**
504
+ * Resolve the effective jail policy from its two sources and render the
505
+ * Hook execution section (hook-jail-config-surface D2/D4).
506
+ *
507
+ * The single catch is the section's one error path: a typo'd env value or a
508
+ * bad stored row renders as a failing check rather than crashing the doctor,
509
+ * because the same throw is about to fail every hook invocation too.
510
+ */
511
+ export function assembleHookExecutionSection(io: HookExecutionIO): {
512
+ lines: string[];
513
+ failCount: number;
514
+ warnCount: number;
515
+ } {
516
+ try {
517
+ const envValue = io.envValue();
518
+ const configValue = io.configValue();
519
+ const resolved = resolveJailPolicy(envValue, configValue);
520
+ return renderHookExecutionSection({
521
+ availability: io.availability(),
522
+ policy: resolved.policy,
523
+ source: resolved.source,
524
+ envValue,
525
+ configValue,
526
+ record: io.record(),
527
+ host: io.host(),
528
+ monitored: io.monitored(),
529
+ });
530
+ } catch (error) {
531
+ return {
532
+ lines: [
533
+ 'Hook execution',
534
+ ` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
535
+ ],
536
+ failCount: 1,
537
+ warnCount: 0,
538
+ };
539
+ }
540
+ }
541
+
542
+ /**
543
+ * The stored `hooks.jail_policy` value, or undefined when there is no row or
544
+ * no celilo DB to ask (a dev box). Same row-read pattern as
545
+ * `ipam/auto-allocator.ts`; the drizzle table is `systemConfig` in
546
+ * `db/schema.ts`.
547
+ */
548
+ function readStoredJailPolicy(): string | undefined {
549
+ if (!existsSync(getDbPath())) return undefined;
550
+ const row = getDb()
551
+ .$client.prepare('SELECT value FROM system_config WHERE key = ?')
552
+ .get('hooks.jail_policy') as { value: string } | undefined;
553
+ return row?.value;
554
+ }
555
+
459
556
  /**
460
557
  * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
461
558
  * package.json). The fleet dispatcher check compares this against the
@@ -736,30 +833,19 @@ export async function handleSystemDoctor(
736
833
  lines.push(...prereqResult.lines);
737
834
  lines.push('');
738
835
 
739
- // Hook execution mode (hook-process-boundary task 4.6). The policy read
740
- // throws on a typo'd CELILO_HOOK_JAIL — report that rather than crash the
836
+ // Hook execution mode (hook-process-boundary task 4.6; sources per D4 of
837
+ // hook-jail-config-surface). The policy read throws on a typo'd
838
+ // CELILO_HOOK_JAIL or a bad stored row — report that rather than crash the
741
839
  // doctor, since the same throw is about to fail every hook invocation too.
742
- let hookExec: { lines: string[]; failCount: number; warnCount: number };
743
- try {
744
- hookExec = renderHookExecutionSection({
745
- availability: detectJailBackend(),
746
- policy: jailPolicy(),
747
- record: readJailMode(),
748
- host: hostname(),
749
- monitored: existsSync(getDbPath())
750
- ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined
751
- : null,
752
- });
753
- } catch (error) {
754
- hookExec = {
755
- lines: [
756
- 'Hook execution',
757
- ` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
758
- ],
759
- failCount: 1,
760
- warnCount: 0,
761
- };
762
- }
840
+ const hookExec = assembleHookExecutionSection({
841
+ envValue: () => process.env.CELILO_HOOK_JAIL,
842
+ configValue: () => readStoredJailPolicy(),
843
+ availability: () => detectJailBackend(),
844
+ record: () => readJailMode(),
845
+ host: () => hostname(),
846
+ monitored: () =>
847
+ existsSync(getDbPath()) ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined : null,
848
+ });
763
849
  lines.push(...hookExec.lines);
764
850
  lines.push('');
765
851
 
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
12
12
  import { mkdtempSync, rmSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join } from 'node:path';
15
+ import { resetTestDbPath } from '../../test-utils/db-path';
15
16
  import { handleSystemInit } from './system-init';
16
17
 
17
18
  interface CapturedStream {
@@ -35,19 +36,21 @@ function captureStderr(): CapturedStream {
35
36
 
36
37
  describe('celilo system init deprecation banner', () => {
37
38
  let tmpDir: string;
38
- let savedDbPath: string | undefined;
39
39
  let savedSuppress: string | undefined;
40
40
 
41
41
  beforeEach(() => {
42
42
  tmpDir = mkdtempSync(join(tmpdir(), 'celilo-deprecation-test-'));
43
- savedDbPath = process.env.CELILO_DB_PATH;
44
43
  savedSuppress = process.env.CELILO_SUPPRESS_DEPRECATION;
45
44
  process.env.CELILO_DB_PATH = join(tmpDir, 'init.db');
46
45
  process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
47
46
  });
48
47
 
49
48
  afterEach(() => {
50
- process.env.CELILO_DB_PATH = savedDbPath;
49
+ // Never restore the previous value: it may be another suite's temp
50
+ // database (or, worse, unset — which sends the next var-less reader to
51
+ // the operator's real celilo.db). The scratch path is the neutral state
52
+ // (celilo#1315).
53
+ resetTestDbPath();
51
54
  process.env.CELILO_SUPPRESS_DEPRECATION = savedSuppress;
52
55
  rmSync(tmpDir, { recursive: true, force: true });
53
56
  });
@@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { closeDb, findMigrationsFolder } from '../../db/client';
7
+ import { resetTestDbPath } from '../../test-utils/db-path';
7
8
  import { handleSystemMigrate } from './system-migrate';
8
9
 
9
10
  /**
@@ -32,7 +33,7 @@ describe('handleSystemMigrate', () => {
32
33
  });
33
34
  afterEach(() => {
34
35
  closeDb();
35
- process.env.CELILO_DB_PATH = undefined;
36
+ resetTestDbPath();
36
37
  try {
37
38
  rmSync(dir, { recursive: true, force: true });
38
39
  } catch {
@@ -362,7 +362,7 @@ _celilo_system_config_keys() {
362
362
  'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
363
363
  'network.secure-mgmt.gateway:Control-plane gateway IP'
364
364
  'network.control-plane-vpn.subnet:Administrative VPN client subnet (WireGuard remote access)'
365
- 'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
365
+ 'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs or JSON array)'
366
366
  'dns.primary:Primary DNS server'
367
367
  'dns.fallback:Fallback DNS servers'
368
368
  'routing.internal_gateway:Internal gateway IP'