@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
package/src/hooks/jail.ts CHANGED
@@ -37,9 +37,57 @@ import { hostname } from 'node:os';
37
37
  import { dirname, join } from 'node:path';
38
38
  import { getDataDir } from '../config/paths';
39
39
  import { type MountSet, type MountSetRequest, toBwrapArgs } from './mount-set';
40
+ import { JAIL_PROVIDED_PATHS } from './unjailed-lint';
40
41
 
41
- /** Which jail celilo can build here. `none` means the hook runs unjailed. */
42
- export type JailBackend = 'bubblewrap' | 'none';
42
+ /**
43
+ * Which jail celilo can build here. `none` means the hook runs unjailed.
44
+ *
45
+ * `sandbox-exec` is in the union ahead of its backend (task 4.8) because the
46
+ * POLICY for it is decided and landed first (ce-29z): `auto` defers on it
47
+ * until D14's declared-path mounts exist, and the planner below enforces
48
+ * that whether or not a platform reports the backend yet.
49
+ */
50
+ export type JailBackend = 'bubblewrap' | 'sandbox-exec' | 'none';
51
+
52
+ /**
53
+ * Whether `auto` may jail through `sandbox-exec`.
54
+ *
55
+ * False on purpose, and the load-bearing fact of ce-29z. Measured there,
56
+ * same hardware, one variable (`CELILO_HOOK_JAIL=off` equals main's
57
+ * behaviour): under bubblewrap a fixture hook's write to an undeclared host
58
+ * path lands in the private tmpfs (D13b) and succeeds silently, while under
59
+ * `sandbox-exec` the same write is `EPERM` — the hook exits non-zero and the
60
+ * full `apps/celilo` suite goes red on macOS where it is green on Linux CI.
61
+ * Sandbox-exec DENIES rather than masks, so the macOS jail is stricter than
62
+ * the Linux one for exactly the paths nobody declared.
63
+ *
64
+ * The mechanism that declares them is D14 (`type: path` on a manifest
65
+ * variable), and D14 is not built speculatively — task 4.2a measured the
66
+ * population and found no consumer that survives D9b. So `auto` defers on
67
+ * this backend until that mechanism lands; flip this to `true` as part of
68
+ * that landing, never before. `CELILO_HOOK_JAIL=required` bypasses the
69
+ * deferral: it is the operator's explicit act (D8), and an operator who sets
70
+ * it on macOS has opted into the strictness.
71
+ */
72
+ const SANDBOX_EXEC_AUTO_JAIL_ENABLED = false;
73
+
74
+ /**
75
+ * Why an `auto` sandbox-exec spawn was deferred. Operator-facing: it names
76
+ * the decision and the one way past it. Exported because `system doctor`
77
+ * renders the same deferral from (policy, availability) alone, where no plan
78
+ * — and so no plan reason — exists yet.
79
+ */
80
+ export const SANDBOX_EXEC_AUTO_DEFERRED_REASON =
81
+ 'macOS hooks run unjailed: sandbox-exec denies (EPERM) a hook write to any host path the mount set does not declare, where bubblewrap masks it into its private tmpfs — undeclared config paths would break module hooks on this host. The declaration mechanism (D14, manifest `type: path`) is not built, so auto defers on this backend (ce-29z). Set CELILO_HOOK_JAIL=required to jail anyway.';
82
+
83
+ /**
84
+ * Does `auto` defer on this backend? Shared by the planner and by
85
+ * `system doctor`, so the doctor cannot report a jail the executor will not
86
+ * build.
87
+ */
88
+ export function autoJailDefers(availability: JailAvailability): boolean {
89
+ return availability.backend === 'sandbox-exec' && !SANDBOX_EXEC_AUTO_JAIL_ENABLED;
90
+ }
43
91
 
44
92
  /**
45
93
  * What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `auto`.
@@ -75,8 +123,8 @@ export interface JailPlan {
75
123
  *
76
124
  * bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
77
125
  * several rows are legitimately absent: `/lib64` does not exist on arm64,
78
- * `<module>/generated` only appears once celilo has generated something, and
79
- * `~/.ssh` need not exist at all. Dropping them is the caller's job rather
126
+ * and `<module>/generated` only appears once celilo has generated
127
+ * something. Dropping them is the caller's job rather
80
128
  * than the derivation's, which is why they are reported rather than silently
81
129
  * filtered — a contract input landing here is a real defect and this is
82
130
  * where it becomes visible.
@@ -104,7 +152,17 @@ export interface JailPlan {
104
152
  * `--new-session` is NOT here. It defends against TIOCSTI injection into a
105
153
  * controlling terminal, and the child is spawned with `stdout: 'pipe'` and no
106
154
  * tty, so there is nothing to inject into.
155
+ *
156
+ * The `--proc`/`--dev` pairs are BUILT from `JAIL_PROVIDED_PATHS`
157
+ * (unjailed-lint.ts), the same list the advisory lint reads, so the spawn and
158
+ * the lint cannot disagree about what the namespace itself provides. Adding a
159
+ * path to that constant without a flag here is a compile error.
107
160
  */
161
+ const JAIL_PROVIDED_FLAGS: Record<(typeof JAIL_PROVIDED_PATHS)[number], string> = {
162
+ '/proc': '--proc',
163
+ '/dev': '--dev',
164
+ };
165
+
108
166
  const JAIL_NAMESPACE_ARGS = [
109
167
  '--unshare-user',
110
168
  '--unshare-ipc',
@@ -113,10 +171,7 @@ const JAIL_NAMESPACE_ARGS = [
113
171
  // The one unshare that legitimately may be unavailable on an older kernel.
114
172
  '--unshare-cgroup-try',
115
173
  '--die-with-parent',
116
- '--proc',
117
- '/proc',
118
- '--dev',
119
- '/dev',
174
+ ...JAIL_PROVIDED_PATHS.flatMap((path) => [JAIL_PROVIDED_FLAGS[path], path]),
120
175
  ] as const;
121
176
 
122
177
  /**
@@ -258,7 +313,6 @@ export function realpathRequest(request: MountSetRequest): MountSetRequest {
258
313
  ...input,
259
314
  value: realpathOrSelf(input.value),
260
315
  })),
261
- sshDir: request.sshDir ? realpathOrSelf(request.sshDir) : undefined,
262
316
  };
263
317
  }
264
318
 
@@ -362,6 +416,14 @@ function realpathOrSelf(path: string): string {
362
416
  * @param set - The derived mount set, or `undefined` when the caller has no
363
417
  * module tree to jail (the executor's own fixtures, and the bus handler path).
364
418
  */
419
+ /**
420
+ * Why an `off` policy runs unjailed, shared with `celilo system doctor` (task
421
+ * 4.6) so the sentence the operator reads there is the sentence the record
422
+ * carries.
423
+ */
424
+ export const JAIL_OFF_REASON =
425
+ 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.';
426
+
365
427
  export function planJailedSpawn(
366
428
  cmd: readonly string[],
367
429
  set: MountSet | undefined,
@@ -374,7 +436,7 @@ export function planJailedSpawn(
374
436
  cmd,
375
437
  mode: 'unjailed',
376
438
  backend: availability.backend,
377
- reason: 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.',
439
+ reason: JAIL_OFF_REASON,
378
440
  skipped: [],
379
441
  };
380
442
  }
@@ -391,7 +453,24 @@ export function planJailedSpawn(
391
453
  return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
392
454
  }
393
455
 
456
+ // ce-29z: auto defers on sandbox-exec until D14 exists. Not an availability
457
+ // question — the backend is there; it is a policy one, which is why this
458
+ // sits in the planner rather than the probe.
459
+ if (policy === 'auto' && autoJailDefers(availability)) {
460
+ return {
461
+ cmd,
462
+ mode: 'unjailed',
463
+ backend: availability.backend,
464
+ reason: SANDBOX_EXEC_AUTO_DEFERRED_REASON,
465
+ skipped: [],
466
+ };
467
+ }
468
+
394
469
  // A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
470
+ // @psbanka-agent note (ce-29z, 2026-09): with SANDBOX_EXEC_AUTO_JAIL_ENABLED
471
+ // still false, the only way to reach this line with a sandbox-exec
472
+ // availability is policy 'required', and task 4.8 owns the spawn arm that
473
+ // makes that real. Until it lands, do not flip the flag.
395
474
  const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
396
475
  const skipped = set.entries.filter((e) => !present.includes(e)).map((e) => e.path);
397
476
 
@@ -423,6 +502,20 @@ export interface JailModeRecord {
423
502
  */
424
503
  readonly host: string;
425
504
  readonly recordedAt: string;
505
+ /**
506
+ * The jailed record this host most recently held, present only on an
507
+ * `unjailed` record that replaced one on the SAME host.
508
+ *
509
+ * This is what makes the jailed-to-unjailed transition durable. The write
510
+ * below overwrites the previous record, so without this field the file for
511
+ * a host that used to jail and has stopped reads identically to a Mac that
512
+ * never jailed — and the self-monitor (task 4.4) runs on a sweep, long
513
+ * after `recordJailMode`'s return value is gone.
514
+ */
515
+ readonly lastJailed?: {
516
+ readonly backend: JailBackend;
517
+ readonly recordedAt: string;
518
+ };
426
519
  }
427
520
 
428
521
  /**
@@ -457,12 +550,14 @@ export function readJailMode(): JailModeRecord | undefined {
457
550
  */
458
551
  export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
459
552
  const previous = readJailMode();
553
+ const host = hostname();
460
554
  const record: JailModeRecord = {
461
555
  mode: plan.mode,
462
556
  backend: plan.backend,
463
557
  ...(plan.reason ? { reason: plan.reason } : {}),
464
- host: hostname(),
558
+ host,
465
559
  recordedAt: new Date().toISOString(),
560
+ ...(lastJailedFor(plan.mode, previous, host) ?? {}),
466
561
  };
467
562
 
468
563
  if (
@@ -489,3 +584,25 @@ export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
489
584
  }
490
585
  return { previous };
491
586
  }
587
+
588
+ /**
589
+ * What an unjailed record remembers about the last time this host jailed.
590
+ *
591
+ * A record from a DIFFERENT host contributes nothing: that is the
592
+ * copied-between-boxes case the `host` field exists for, and reading it as a
593
+ * transition would alert on a move that regressed nothing. A jailed record
594
+ * remembers nothing either — re-jailing is the recovery, and a later
595
+ * regression should date from the NEW jailed record, not the first ever.
596
+ */
597
+ function lastJailedFor(
598
+ mode: JailMode,
599
+ previous: JailModeRecord | undefined,
600
+ host: string,
601
+ ): Pick<JailModeRecord, 'lastJailed'> | undefined {
602
+ if (mode !== 'unjailed' || !previous || previous.host !== host) return undefined;
603
+ if (previous.mode === 'jailed') {
604
+ return { lastJailed: { backend: previous.backend, recordedAt: previous.recordedAt } };
605
+ }
606
+ // unjailed → unjailed (a backend or reason change): the memory rides along.
607
+ return previous.lastJailed ? { lastJailed: previous.lastJailed } : undefined;
608
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
+ import { BROWSER_ROOT } from '@celilo/capabilities';
10
11
  import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
11
12
 
12
13
  const BASE = {
@@ -136,13 +137,34 @@ describe('paths are identical inside and outside', () => {
136
137
  });
137
138
  });
138
139
 
139
- describe('~/.ssh is stage 2 only', () => {
140
- test('bound read-only when supplied', () => {
141
- const set = deriveMountSet({ ...BASE, sshDir: '/var/celilo/.ssh' });
142
- expect(set.entries.find((e) => e.path === '/var/celilo/.ssh')?.mode).toBe('ro');
140
+ describe('~/.ssh is never in the mount set (stage 3, D12)', () => {
141
+ test('no derivation produces an .ssh row', () => {
142
+ // Withholding the key is what turns the remote-ops broker's target check
143
+ // from a convention into a boundary: with no credential in the jail, a
144
+ // hand-built `ssh` cannot authenticate anywhere.
145
+ expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
143
146
  });
147
+ });
144
148
 
145
- test('absent when not supplied, which is what stage 3 does', () => {
146
- expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
149
+ describe('the fleet browser is reachable, and only read-only (task 4.10)', () => {
150
+ test('BROWSER_ROOT is bound', () => {
151
+ // Task 4.10 names `~/.cache/ms-playwright`. That path is stale:
152
+ // `managed-browser-runtime` moved the browser into a celilo-owned tree,
153
+ // and `resolveBrowser()` — the thing a hook actually asks — returns
154
+ // `BROWSER_EXECUTABLE_PATH` under this root. Asserted against the exported
155
+ // constant rather than a literal, so a future move cannot leave this test
156
+ // passing about a directory nothing launches from.
157
+ expect(pathsOf(BASE)).toContain(BROWSER_ROOT);
158
+ });
159
+
160
+ test('read-only, because a hook has no business writing the shared install', () => {
161
+ expect(deriveMountSet(BASE).entries.find((e) => e.path === BROWSER_ROOT)?.mode).toBe('ro');
162
+ });
163
+
164
+ test('the data directory beside it stays out', () => {
165
+ // The row is one named subdirectory, not `/var/lib/celilo`. Binding the
166
+ // parent would put `celilo.db` and `master.key` back inside the jail —
167
+ // the exact acceptance criterion D9 satisfies by absence.
168
+ expect(pathsOf(BASE)).not.toContain('/var/lib/celilo');
147
169
  });
148
170
  });
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { dirname, isAbsolute, join, resolve } from 'node:path';
20
- import type { PathAccess } from '@celilo/capabilities';
20
+ import { BROWSER_ROOT, type PathAccess } from '@celilo/capabilities';
21
21
 
22
22
  /**
23
23
  * `tmpfs` is not an access level, it is "put a fresh empty filesystem here".
@@ -105,15 +105,6 @@ export interface MountSetRequest {
105
105
  readonly runtimeModulePaths?: readonly string[];
106
106
  /** Contract-declared path inputs, already resolved to values. */
107
107
  readonly pathInputs: readonly DeclaredPathInput[];
108
- /**
109
- * The operator's `~/.ssh`, read-only, STAGE 2 ONLY.
110
- *
111
- * `remote.ts` still runs inside the hook and needs the key. Stage 3 brokers
112
- * those calls and drops this row, which is what turns D12's target check
113
- * from a convention into a boundary. Dropping it before stage 3 lands
114
- * hardens nothing — it just stops every hook reaching its own systems.
115
- */
116
- readonly sshDir?: string;
117
108
  }
118
109
 
119
110
  /** Directories whose contents the runtime needs in order to start at all. */
@@ -181,6 +172,34 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
181
172
  for (const dir of RUNTIME_SUPPORT_DIRS) {
182
173
  entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
183
174
  }
175
+ // The fleet browser, read-only (task 4.10).
176
+ //
177
+ // `BROWSER_ROOT` rather than `~/.cache/ms-playwright`, which is what task 4.10
178
+ // says to bind and is now the wrong path: `managed-browser-runtime` moved the
179
+ // browser into a celilo-owned tree and `resolveBrowser()` is what a hook asks
180
+ // for it. Binding the cache would bind a directory nothing launches from.
181
+ //
182
+ // Read-only because a hook has no business writing to the shared browser
183
+ // install, and unconditional because it costs nothing when absent —
184
+ // `planJailedSpawn` drops a row whose source does not exist, so a host with no
185
+ // provisioned browser gets no row and no error. Gating it on some
186
+ // "this module uses a browser" signal would need a declaration that does not
187
+ // exist, and would fail closed in the one case that matters.
188
+ //
189
+ // Note what this does NOT bind: `/var/lib/celilo` itself stays out, so the
190
+ // data directory beside it is as absent as it was before. This is one
191
+ // subdirectory, named explicitly.
192
+ //
193
+ // ⚠️ The bind is necessary and not sufficient. Measured 2026-08-28 under a
194
+ // real bubblewrap jail (`hook-jail-toolchain-reach.test.ts`): Chromium with
195
+ // its own sandbox does NOT start inside the jail, and the same binary with
196
+ // `--no-sandbox` renders. Chromium's sandbox forks a helper into a new user
197
+ // namespace and nesting that inside bubblewrap's unprivileged one fails. So a
198
+ // browser hook also needs `--no-sandbox`, which belongs to whatever launches
199
+ // the browser rather than here. That interim landed 2026-08-31 (celilo#1215):
200
+ // the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag,
201
+ // and `jail-browser-launch-flags.test.ts` pins it there.
202
+ entries.push(entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned'));
184
203
 
185
204
  // 3. The module's own tree, read-only, then its writable directories carved
186
205
  // on top. bubblewrap resolves that in the right order, which is why the
@@ -215,16 +234,11 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
215
234
  );
216
235
  }
217
236
 
218
- // 6. Stage 2 only. See MountSetRequest.sshDir.
219
- if (request.sshDir) {
220
- entries.push(
221
- entry(
222
- resolve(request.sshDir),
223
- 'ro',
224
- 'remote.ts needs the key until the broker holds it (D12)',
225
- ),
226
- );
227
- }
237
+ // There is deliberately NO `~/.ssh` row (stage 3, design D12). With no key
238
+ // in the jail, a hand-built `ssh` cannot authenticate anywhere; the remote
239
+ // primitives cross to the remote-ops broker instead, and withholding the
240
+ // key is what makes that broker's target check a boundary rather than a
241
+ // convention.
228
242
 
229
243
  return {
230
244
  entries: entries.filter((e) => !isForbidden(e.path)),