@celilo/cli 1.13.0 → 2.0.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 (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The wiring, end to end: does `celilo console get` actually carry the chain?
3
+ *
4
+ * `orderFirewallChain` is unit-tested, `computeClosure` is unit-tested, and
5
+ * `loadClosureInputs` is unit-tested against real rows. None of that says the
6
+ * command joins them up. A dropped argument at this one call site returns a
7
+ * perfectly well-formed answer with an empty `chains` — which reads as a fleet
8
+ * with no delegation, and is the failure mode CLAUDE.md names.
9
+ *
10
+ * So this drives the REAL command against a REAL database and asserts on what
11
+ * it emits.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ let testDir: string;
20
+
21
+ beforeEach(() => {
22
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-console-chain-'));
23
+ process.env.CELILO_DB_PATH = join(testDir, 'test.db');
24
+ process.env.CELILO_DATA_DIR = testDir;
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(testDir, { recursive: true, force: true });
29
+ process.env.CELILO_DB_PATH = undefined;
30
+ });
31
+
32
+ /** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
33
+ async function seedFleet() {
34
+ const { getDb } = await import('../../db/client');
35
+ const db = getDb();
36
+ const consumer = JSON.stringify({ requires: { capabilities: [{ name: 'firewall' }] } });
37
+
38
+ db.$client.run(
39
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
40
+ VALUES ('caddy', 'Caddy', '1.0.0', '/p', '${consumer}', 'VERIFIED')`,
41
+ );
42
+ for (const id of ['iptables', 'axon']) {
43
+ db.$client.run(
44
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
45
+ VALUES ('${id}', '${id}', '1.0.0', '/p', '{}', 'VERIFIED')`,
46
+ );
47
+ }
48
+ db.$client.run(
49
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
50
+ VALUES ('iptables', 'firewall', '1.0.0', '{"nat_ip":"192.168.0.253"}')`,
51
+ );
52
+ db.$client.run(
53
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
54
+ VALUES ('axon', 'firewall', '1.0.0', '{"has_external":true}')`,
55
+ );
56
+ }
57
+
58
+ describe('celilo console get, and the delegation chain', () => {
59
+ test('--json carries the chain, in order', async () => {
60
+ await seedFleet();
61
+ const { handleConsoleGet } = await import('./console');
62
+ const result = handleConsoleGet(['caddy'], { json: true });
63
+
64
+ expect(result.success).toBe(true);
65
+ const payload = JSON.parse(result.success ? result.message : '{}');
66
+ expect(payload.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
67
+ });
68
+
69
+ test('the router is in the closure, though no manifest mentions it', async () => {
70
+ // caddy requires `firewall` and nothing else. Walking manifests alone stops
71
+ // at the two registered providers and has no way to know one stands on the
72
+ // other — which is the whole reason the chain is derived.
73
+ await seedFleet();
74
+ const { handleConsoleGet } = await import('./console');
75
+ const payload = JSON.parse(
76
+ (() => {
77
+ const r = handleConsoleGet(['caddy'], { json: true });
78
+ return r.success ? r.message : '{}';
79
+ })(),
80
+ );
81
+
82
+ const axon = payload.nodes.find((n: { moduleId: string }) => n.moduleId === 'axon');
83
+ expect(axon).toBeDefined();
84
+ });
85
+
86
+ test('the human output prints it as a path, not as more rows', async () => {
87
+ // A list of two firewalls is exactly the claim being corrected. The arrow is
88
+ // the information.
89
+ await seedFleet();
90
+ const { handleConsoleGet } = await import('./console');
91
+ const result = handleConsoleGet(['caddy'], {});
92
+
93
+ expect(result.success).toBe(true);
94
+ expect(result.success ? result.message : '').toContain('iptables -> axon');
95
+ });
96
+ });
@@ -73,7 +73,7 @@ export function handleConsoleGet(
73
73
  const depth = parseDepth(flags.depth);
74
74
  if (depth instanceof Error) return { success: false, error: depth.message };
75
75
 
76
- const { manifests, providerStates } = loadClosureInputs(db);
76
+ const { manifests, providerStates, chains } = loadClosureInputs(db);
77
77
 
78
78
  // Real bindings, for every module the walk might reach. The console draws what
79
79
  // the fleet IS doing, not what its manifests permit.
@@ -86,6 +86,7 @@ export function handleConsoleGet(
86
86
  providerStates,
87
87
  depth,
88
88
  bindings,
89
+ chains,
89
90
  });
90
91
 
91
92
  if (hasFlag(flags, 'json')) {
@@ -98,14 +99,21 @@ export function handleConsoleGet(
98
99
  return { success: true, message: `${moduleId} depends on nothing.` };
99
100
  }
100
101
 
102
+ // The chain is printed as a path rather than as more rows, because that is the
103
+ // whole claim: these providers are ordered, and a list of them is not.
104
+ const chainLines = result.chains.map(
105
+ (chain) => `${chain.capability}: ${chain.moduleIds.join(' -> ')}`,
106
+ );
107
+
101
108
  return {
102
109
  success: true,
103
- message: result.nodes
104
- .map(
110
+ message: [
111
+ ...result.nodes.map(
105
112
  (n) =>
106
113
  `hop ${n.hop} ${n.moduleId.padEnd(24)} ${n.optional ? '(optional)' : ' '} via ${n.via.join(', ')}`,
107
- )
108
- .join('\n'),
114
+ ),
115
+ ...(chainLines.length > 0 ? ['', 'delegates upstream:', ...chainLines] : []),
116
+ ].join('\n'),
109
117
  };
110
118
  }
111
119
 
@@ -6,10 +6,12 @@
6
6
  * and audit checks into the injectable deps `runOneMonitor` expects.
7
7
  */
8
8
 
9
+ import { hostname } from 'node:os';
9
10
  import { defineEvents, openBus } from '@celilo/event-bus';
10
11
  import { getEventBusPath } from '../../config/paths';
11
12
  import { getDb } from '../../db/client';
12
13
  import type { MonitorKind } from '../../db/schema';
14
+ import { readJailMode } from '../../hooks/jail';
13
15
  import {
14
16
  isSchedulableBuiltin,
15
17
  runBuiltinCheckForMonitor,
@@ -17,6 +19,7 @@ import {
17
19
  import { loadModuleCoverage } from '../../services/alerting/coverage-source';
18
20
  import { loadModuleHealthCadences } from '../../services/alerting/health-cadence';
19
21
  import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
22
+ import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
20
23
  import {
21
24
  createMonitor,
22
25
  ensureSweepSubscriber,
@@ -50,6 +53,7 @@ function buildDeps() {
50
53
  runModuleHealthCheck(moduleId, db, { unattended: true, noInteractive: true }),
51
54
  runBuiltinCheck: (category: DriftCategory) => runBuiltinCheckForMonitor(category, db),
52
55
  loadModuleCoverage: () => loadModuleCoverage(db),
56
+ loadJailState: () => ({ record: readJailMode(), host: hostname() }),
53
57
  now: () => new Date(),
54
58
  graceMs: DEFAULT_GRACE_MS,
55
59
  };
@@ -120,7 +124,10 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
120
124
  // underscore heuristic alone would file it as a module hook against a module
121
125
  // that does not exist.
122
126
  const kind: MonitorKind =
123
- target === HEALTH_COVERAGE_CHECK || isSchedulableBuiltin(target) || target.includes('_')
127
+ target === HEALTH_COVERAGE_CHECK ||
128
+ target === HOOK_JAIL_CHECK ||
129
+ isSchedulableBuiltin(target) ||
130
+ target.includes('_')
124
131
  ? 'builtin_check'
125
132
  : 'module_hook';
126
133
 
@@ -148,7 +155,13 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
148
155
  const cadence = parseCadence(interval);
149
156
  const intervalMinutes = cadence !== null && cadence !== 'manual' ? cadence.minutes : 0;
150
157
 
151
- createMonitor(db, { kind, target, intervalMinutes });
158
+ // The hook-jail check is a SELF-monitor: it watches celilo's own confinement
159
+ // of module hooks, and the alerting spec marks monitors watching the system
160
+ // itself unsuppressible. That is a property of the target, not an operator
161
+ // choice — losing the jail during a broad outage is exactly when no ancestor
162
+ // alert may silence it.
163
+ const suppressible = target === HOOK_JAIL_CHECK ? false : undefined;
164
+ createMonitor(db, { kind, target, intervalMinutes, suppressible });
152
165
 
153
166
  // Creating the first monitor is also what switches the sweep on. Registering
154
167
  // here rather than at install means a celilo with no monitors carries no
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `celilo person list --json` — the shape the web console maps an identity
3
+ * against.
4
+ *
5
+ * Worth its own test for a reason that is not obvious from the three lines it
6
+ * covers. The console server decides whether an authenticated browser subject
7
+ * corresponds to a real person, and it decides it by reading THIS. Its own
8
+ * tests answer that question against a fake celilo, so they prove the console
9
+ * parses what it was told to expect and say nothing about what celilo emits.
10
+ * This is the end that closes.
11
+ *
12
+ * If the payload ever stops being `[{ name, timezone }]`, the console does not
13
+ * error: it maps nobody, draws no acknowledge control, and reports every
14
+ * operator as unknown to the fleet. That reads as a configuration problem for
15
+ * as long as anyone is willing to believe it.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { mkdtempSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { closeDb, getDb } from '../../db/client';
23
+ import { createPerson } from '../../services/alerting/people';
24
+ import { setupTestDatabaseAt } from '../../test-utils/database';
25
+ import { handlePerson } from './notify-config';
26
+
27
+ let dir: string;
28
+ const previousDbPath = process.env.CELILO_DB_PATH;
29
+
30
+ beforeEach(async () => {
31
+ dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
32
+ const dbPath = join(dir, 'test.db');
33
+ const db = await setupTestDatabaseAt(dbPath);
34
+ createPerson(db, { name: 'peter', timezone: 'America/Los_Angeles' });
35
+ createPerson(db, { name: 'ada', timezone: 'Europe/London' });
36
+ db.$client.close();
37
+ process.env.CELILO_DB_PATH = dbPath;
38
+ });
39
+
40
+ afterEach(() => {
41
+ closeDb();
42
+ if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
43
+ else process.env.CELILO_DB_PATH = previousDbPath;
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ /** Run `person list`, insisting it succeeded before reading what it said. */
48
+ async function personList(flags: Record<string, boolean | string>): Promise<string> {
49
+ const result = await handlePerson('list', [], flags);
50
+ if (!result.success) throw new Error(`person list failed: ${result.error}`);
51
+ return result.message;
52
+ }
53
+
54
+ async function listJson(): Promise<{ name: string; timezone: string }[]> {
55
+ return JSON.parse(await personList({ json: true }));
56
+ }
57
+
58
+ describe('celilo person list --json', () => {
59
+ test('names every person, sorted the way listPeople sorts them', async () => {
60
+ expect((await listJson()).map((p) => p.name)).toEqual(['ada', 'peter']);
61
+ });
62
+
63
+ test('carries the timezone, which is the only other thing a person IS', async () => {
64
+ const ada = (await listJson()).find((p) => p.name === 'ada');
65
+ expect(ada?.timezone).toBe('Europe/London');
66
+ });
67
+
68
+ test('an empty fleet is an empty array, not a sentence about it', async () => {
69
+ // The human listing prints "Nobody configured." and a hint. A caller that
70
+ // parsed that would throw, which is a better failure than the one this
71
+ // avoids — but the console polls this, so it gets JSON either way.
72
+ getDb().$client.run('DELETE FROM people');
73
+ expect(await listJson()).toEqual([]);
74
+ });
75
+
76
+ test('without --json it stays the human table, and says how many', async () => {
77
+ expect(await personList({})).toContain('2 person');
78
+ });
79
+ });
@@ -33,6 +33,7 @@ import {
33
33
  listRoutes,
34
34
  } from '../../services/alerting/people';
35
35
  import { parseClockTime } from '../../services/alerting/quiet-hours';
36
+ import { hasFlag } from '../parser';
36
37
  import type { CommandResult } from '../types';
37
38
 
38
39
  const NO_SCHEMAS = defineEvents({});
@@ -109,8 +110,18 @@ function personAdd(args: string[], flags: Record<string, boolean | string>): Com
109
110
  return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
110
111
  }
111
112
 
112
- function personList(): CommandResult {
113
+ function personList(flags: Record<string, boolean | string> = {}): CommandResult {
113
114
  const people = listPeople(getDb());
115
+
116
+ // The web console's subject-to-person mapping reads this. It never invents a
117
+ // person and never lets celilo fall back to `people[0]`, so it has to be able
118
+ // to ASK who exists. `person:list` is already in the read-only grant set, so
119
+ // the flag adds a shape rather than an authority.
120
+ if (hasFlag(flags, 'json')) {
121
+ const payload = people.map((p) => ({ name: p.name, timezone: p.timezone }));
122
+ return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
123
+ }
124
+
114
125
  if (people.length === 0) {
115
126
  console.log('\nNobody configured.\n');
116
127
  console.log(
@@ -158,7 +169,7 @@ export async function handlePerson(
158
169
  switch (subcommand) {
159
170
  case undefined:
160
171
  case 'list':
161
- return personList();
172
+ return personList(flags);
162
173
  case 'add':
163
174
  return personAdd(args, flags);
164
175
  case 'remove':
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { compareVersions } from './system-doctor';
2
+ import type { JailModeRecord } from '../../hooks/jail';
3
+ import {
4
+ type HookExecutionInput,
5
+ compareVersions,
6
+ renderHookExecutionSection,
7
+ } from './system-doctor';
3
8
 
4
9
  describe('compareVersions', () => {
5
10
  test('detects ascending major/minor/patch', () => {
@@ -34,3 +39,118 @@ describe('compareVersions', () => {
34
39
  expect(compareVersions('v1.2.3', '1.2.3')).toBe(0);
35
40
  });
36
41
  });
42
+
43
+ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
44
+ const HOST = 'celilo-mgr';
45
+ const jailedRecord: JailModeRecord = {
46
+ mode: 'jailed',
47
+ backend: 'bubblewrap',
48
+ host: HOST,
49
+ recordedAt: '2026-08-28T09:00:00.000Z',
50
+ };
51
+ const regressedRecord: JailModeRecord = {
52
+ mode: 'unjailed',
53
+ backend: 'none',
54
+ reason: 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.',
55
+ host: HOST,
56
+ recordedAt: '2026-08-28T09:00:00.000Z',
57
+ lastJailed: { backend: 'bubblewrap', recordedAt: '2026-08-27T09:00:00.000Z' },
58
+ };
59
+
60
+ const render = (over: Partial<HookExecutionInput>) =>
61
+ renderHookExecutionSection({
62
+ availability: { backend: 'bubblewrap' },
63
+ policy: 'auto',
64
+ record: jailedRecord,
65
+ host: HOST,
66
+ monitored: true,
67
+ ...over,
68
+ });
69
+
70
+ const text = (r: { lines: string[] }) => r.lines.join('\n');
71
+
72
+ test('a jailing host is a ✔ and nothing more', () => {
73
+ const r = render({});
74
+ expect(text(r)).toContain('hooks run jailed (bubblewrap)');
75
+ expect(r.failCount).toBe(0);
76
+ expect(r.warnCount).toBe(0);
77
+ });
78
+
79
+ test('unjailed says why in the reason sentence, as a warning and not a failure', () => {
80
+ const r = render({
81
+ availability: {
82
+ backend: 'none',
83
+ reason:
84
+ 'bubblewrap is not installed, so hooks run unjailed. Install it (`apt install bubblewrap`) and re-run.',
85
+ },
86
+ record: undefined,
87
+ monitored: null,
88
+ });
89
+ expect(text(r)).toContain('hooks run unjailed — bubblewrap is not installed');
90
+ expect(text(r)).toContain('no execution mode recorded yet');
91
+ expect(r.warnCount).toBe(1);
92
+ expect(r.failCount).toBe(0);
93
+ });
94
+
95
+ test('CELILO_HOOK_JAIL=off is reported as the operator’s own act', () => {
96
+ const r = render({ policy: 'off', record: undefined });
97
+ expect(text(r)).toContain('CELILO_HOOK_JAIL=off');
98
+ expect(r.warnCount).toBe(1);
99
+ });
100
+
101
+ test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
102
+ // The doctor and the executor share one predicate (autoJailDefers), so
103
+ // the doctor cannot report a jail the executor will not build. When D14
104
+ // lands and the flag flips, this goes back to ✔ with no code change here.
105
+ const r = render({ availability: { backend: 'sandbox-exec' }, record: undefined });
106
+ expect(text(r)).toContain('hooks run unjailed — macOS hooks run unjailed');
107
+ expect(text(r)).not.toContain('hooks run jailed');
108
+ expect(r.warnCount).toBe(1);
109
+ expect(r.failCount).toBe(0);
110
+ });
111
+
112
+ test('required with no backend is a failure, because every hook fails', () => {
113
+ const r = render({
114
+ policy: 'required',
115
+ availability: { backend: 'none', reason: 'the profile did not load' },
116
+ record: undefined,
117
+ });
118
+ expect(text(r)).toContain('CELILO_HOOK_JAIL=required and no jail is available');
119
+ expect(r.failCount).toBe(1);
120
+ });
121
+
122
+ test('a host that used to jail and has stopped is a failure naming when', () => {
123
+ const r = render({
124
+ availability: { backend: 'none', reason: 'the profile did not load' },
125
+ record: regressedRecord,
126
+ });
127
+ expect(text(r)).toContain('ran hooks jailed (bubblewrap) until 2026-08-27T09:00:00.000Z');
128
+ expect(r.failCount).toBe(1);
129
+ });
130
+
131
+ test('an unmonitored regression points at the self-monitor', () => {
132
+ const r = render({
133
+ availability: { backend: 'none', reason: 'the profile did not load' },
134
+ record: regressedRecord,
135
+ monitored: false,
136
+ });
137
+ expect(text(r)).toContain('celilo monitor add hook_jail');
138
+ });
139
+
140
+ test('a jailing but unmonitored host is told the transition alerts only if monitored', () => {
141
+ const r = render({ monitored: false });
142
+ expect(text(r)).toContain('celilo monitor add hook_jail');
143
+ expect(r.failCount).toBe(0);
144
+ });
145
+
146
+ test('a dev box with no DB gets no monitor hint', () => {
147
+ const r = render({ monitored: null });
148
+ expect(text(r)).not.toContain('monitor add');
149
+ });
150
+
151
+ test("another host's record reads as a move, not this host's history", () => {
152
+ const r = render({ record: { ...regressedRecord, host: 'old-box' } });
153
+ expect(text(r)).toContain('belongs to host "old-box"');
154
+ expect(r.failCount).toBe(0);
155
+ });
156
+ });
@@ -32,13 +32,27 @@
32
32
  import { spawnSync } from 'node:child_process';
33
33
  import { existsSync, readFileSync, statSync } from 'node:fs';
34
34
  import { createRequire } from 'node:module';
35
+ import { hostname } from 'node:os';
35
36
  import { dirname, join, resolve } from 'node:path';
36
37
  import { resolveBrowser } from '@celilo/capabilities';
37
38
  import { defineEvents, openBus } from '@celilo/event-bus';
38
39
  import cliPkg from '../../../package.json' with { type: 'json' };
39
40
  import { getDbPath, getEventBusPath } from '../../config/paths';
40
41
  import { getDb } from '../../db/client';
42
+ import {
43
+ JAIL_OFF_REASON,
44
+ type JailAvailability,
45
+ type JailModeRecord,
46
+ type JailPolicy,
47
+ SANDBOX_EXEC_AUTO_DEFERRED_REASON,
48
+ autoJailDefers,
49
+ detectJailBackend,
50
+ jailPolicy,
51
+ readJailMode,
52
+ } from '../../hooks/jail';
41
53
  import type { ModuleManifest } from '../../manifest/schema';
54
+ import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
55
+ import { findMonitorByTarget } from '../../services/alerting/monitors';
42
56
  import {
43
57
  type FleetFinding,
44
58
  type FleetFindingStatus,
@@ -336,6 +350,112 @@ function provisionedBrowserFlavor(): string | null {
336
350
  }
337
351
  }
338
352
 
353
+ export interface HookExecutionInput {
354
+ availability: JailAvailability;
355
+ policy: JailPolicy;
356
+ /** What the last real module-hook run recorded, if any. */
357
+ record: JailModeRecord | undefined;
358
+ host: string;
359
+ /**
360
+ * Whether a `hook_jail` monitor exists. `null` when there is no celilo DB to
361
+ * ask (a dev box), which also suppresses the add-the-monitor hint.
362
+ */
363
+ monitored: boolean | null;
364
+ }
365
+
366
+ /**
367
+ * The hook-execution section (hook-process-boundary task 4.6).
368
+ *
369
+ * Reports design D8's three states the way the design assigns them: jailed is
370
+ * a ✔ and nothing more; unjailed-and-always-was is reported with the reason in
371
+ * one actionable sentence and is a warning, not an event; a host that USED to
372
+ * jail and has stopped is a failure, and the self-monitor (`celilo monitor add
373
+ * hook_jail`) is what turns it into an alert.
374
+ *
375
+ * The headline is the LIVE answer — the probe runs bubblewrap the way a real
376
+ * spawn would — while the recorded mode says what the last hook actually did.
377
+ * They disagree exactly when the host changed since the last hook ran, which
378
+ * is worth seeing, not reconciling away.
379
+ *
380
+ * Pure so every branch is testable on a host with no jail (Rule 10.4); the
381
+ * caller injects what the machine says.
382
+ */
383
+ export function renderHookExecutionSection(input: HookExecutionInput): {
384
+ lines: string[];
385
+ failCount: number;
386
+ warnCount: number;
387
+ } {
388
+ const { availability, policy, record, host, monitored } = input;
389
+ const lines: string[] = ['Hook execution'];
390
+ let failCount = 0;
391
+ let warnCount = 0;
392
+
393
+ // ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
394
+ // the executor will not build. One predicate, two consumers.
395
+ const autoDeferred = policy === 'auto' && autoJailDefers(availability);
396
+ const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
397
+ if (policy === 'required' && availability.backend === 'none') {
398
+ lines.push(
399
+ ` ${ANSI.red}✗${ANSI.reset} CELILO_HOOK_JAIL=required and no jail is available — every module hook on this host fails rather than run unjailed`,
400
+ );
401
+ lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
402
+ failCount++;
403
+ } else if (policy === 'off') {
404
+ lines.push(` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${JAIL_OFF_REASON}`);
405
+ warnCount++;
406
+ } else if (availability.backend === 'none') {
407
+ lines.push(
408
+ ` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${availability.reason ?? 'no reason was recorded'}`,
409
+ );
410
+ warnCount++;
411
+ } else if (autoDeferred) {
412
+ lines.push(
413
+ ` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${SANDBOX_EXEC_AUTO_DEFERRED_REASON}`,
414
+ );
415
+ warnCount++;
416
+ } else {
417
+ lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
418
+ }
419
+
420
+ const regressed =
421
+ record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
422
+ if (regressed && record.lastJailed) {
423
+ lines.push(
424
+ ` ${ANSI.red}✗${ANSI.reset} this host ran hooks jailed (${record.lastJailed.backend}) until ${record.lastJailed.recordedAt} and has stopped`,
425
+ );
426
+ if (record.reason) lines.push(` ${ANSI.dim}${record.reason}${ANSI.reset}`);
427
+ failCount++;
428
+ if (monitored === false) {
429
+ lines.push(
430
+ ` ${ANSI.dim}→ nothing alerts on this — celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
431
+ );
432
+ }
433
+ } else if (!record) {
434
+ lines.push(
435
+ ` ${ANSI.dim}no execution mode recorded yet — the first real module-hook run writes it${ANSI.reset}`,
436
+ );
437
+ } else if (record.host !== host) {
438
+ lines.push(
439
+ ` ${ANSI.dim}last recorded mode belongs to host "${record.host}" (this data directory moved) — the next hook run re-records${ANSI.reset}`,
440
+ );
441
+ } else {
442
+ const backend = record.mode === 'jailed' ? ` (${record.backend})` : '';
443
+ lines.push(
444
+ ` ${ANSI.dim}last hook ran ${record.mode}${backend} at ${record.recordedAt}${ANSI.reset}`,
445
+ );
446
+ }
447
+
448
+ // The moment this hint pays for itself is BEFORE a regression: the monitor
449
+ // has to exist for the transition to page anyone.
450
+ if (liveJailed && monitored === false) {
451
+ lines.push(
452
+ ` ${ANSI.dim}→ a host that stops jailing alerts only if monitored: celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
453
+ );
454
+ }
455
+
456
+ return { lines, failCount, warnCount };
457
+ }
458
+
339
459
  /**
340
460
  * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
341
461
  * package.json). The fleet dispatcher check compares this against the
@@ -616,6 +736,33 @@ export async function handleSystemDoctor(
616
736
  lines.push(...prereqResult.lines);
617
737
  lines.push('');
618
738
 
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
741
+ // 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
+ }
763
+ lines.push(...hookExec.lines);
764
+ lines.push('');
765
+
619
766
  const workspaceRoot = findWorkspaceRoot(process.cwd());
620
767
  const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
621
768
  const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
@@ -748,6 +895,9 @@ export async function handleSystemDoctor(
748
895
  }
749
896
  if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
750
897
  if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
898
+ if (hookExec.failCount > 0) {
899
+ problems.push(`${hookExec.failCount} hook-execution problem(s)`);
900
+ }
751
901
  if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
752
902
  if (aspectFailCount > 0) problems.push(`${aspectFailCount} system(s) missing a fleet aspect`);
753
903
 
@@ -760,7 +910,7 @@ export async function handleSystemDoctor(
760
910
  }
761
911
 
762
912
  // Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
763
- const warnTotal = fleet.warnCount + aspectWarnCount;
913
+ const warnTotal = fleet.warnCount + aspectWarnCount + hookExec.warnCount;
764
914
  if (warnTotal > 0) {
765
915
  lines.push(`${ANSI.yellow}OK with warnings${ANSI.reset} — ${warnTotal} warning(s); see above`);
766
916
  } else {