@celilo/cli 1.14.0 → 2.1.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 (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -3
  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-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +74 -12
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +254 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. package/src/services/static-content-converge.ts +299 -0
@@ -32,6 +32,7 @@ import {
32
32
  planFallbacks,
33
33
  } from '../../../../scripts/workspace-fallback';
34
34
  import { executeHookScript, hookChildEnv } from './executor';
35
+ import { detectJailBackend, jailPolicy } from './jail';
35
36
  import { createCapturingLogger } from './logger';
36
37
  import type { HookContext } from './types';
37
38
 
@@ -150,7 +151,7 @@ afterAll(() => {
150
151
  rmSync(scratchHome, { recursive: true, force: true });
151
152
  });
152
153
 
153
- async function runTrespass(): Promise<{ report: TrespassReport; lines: string[] }> {
154
+ async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
154
155
  const { logger, messages } = createCapturingLogger();
155
156
  const context: HookContext = {
156
157
  config: { sibling_module_id: 'hello-foo', other_system_ip: '' },
@@ -163,14 +164,28 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
163
164
  capabilities: {},
164
165
  };
165
166
 
166
- const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
167
- timeoutMs: 60_000,
168
- idleTimeoutMs: 60_000,
169
- });
170
- return {
171
- report: outputs as unknown as TrespassReport,
172
- lines: messages.map((m) => m.message),
173
- };
167
+ // The jailed run jails under `required`, not the `auto` default. ce-29z
168
+ // made `auto` defer on sandbox-exec until D14 exists (undeclared host-path
169
+ // writes would break module hooks), so the test's jail is the operator's
170
+ // explicit act — exactly the bypass the deferral leaves open.
171
+ const savedPolicy = process.env.CELILO_HOOK_JAIL;
172
+ if (jail) process.env.CELILO_HOOK_JAIL = 'required';
173
+ try {
174
+ const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
175
+ timeoutMs: 60_000,
176
+ idleTimeoutMs: 60_000,
177
+ // The module's own tree, two levels above the hook script. Absent means
178
+ // "run unjailed", which is what the stage 1 assertions below need.
179
+ ...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
180
+ });
181
+ return {
182
+ report: outputs as unknown as TrespassReport,
183
+ lines: messages.map((m) => m.message),
184
+ };
185
+ } finally {
186
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
187
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
188
+ }
174
189
  }
175
190
 
176
191
  describe('the child environment is an allow-list', () => {
@@ -178,12 +193,13 @@ describe('the child environment is an allow-list', () => {
178
193
  // complement: the six things that MUST get through, because a hook with no
179
194
  // `PATH` cannot spawn anything and one with no `HOME` breaks more than it
180
195
  // protects.
181
- test('forwards the allow-list and the channel, and nothing else', () => {
196
+ test('forwards the allow-list and the channels, and nothing else', () => {
182
197
  process.env.CELILO_TEST_SECRET = 'must-not-cross';
183
198
  try {
184
- const env = hookChildEnv('/tmp/celilo-hook-x/s');
199
+ const env = hookChildEnv('/tmp/celilo-hook-x/s', '/tmp/celilo-hook-x/r', undefined);
185
200
 
186
201
  expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
202
+ expect(env.CELILO_HOOK_REMOTE_SOCKET).toBe('/tmp/celilo-hook-x/r');
187
203
  expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
188
204
  expect(env.PATH).toBe(process.env.PATH as string);
189
205
  expect(env.HOME).toBe(process.env.HOME as string);
@@ -197,9 +213,14 @@ describe('the child environment is an allow-list', () => {
197
213
  'TMPDIR',
198
214
  'CELILO_DEBUG',
199
215
  'CELILO_HOOK_SOCKET',
216
+ 'CELILO_HOOK_REMOTE_SOCKET',
200
217
  'CELILO_HOOK_PROTOCOL_VERSION',
218
+ 'CELILO_HOOK_MOUNT_SET',
201
219
  ]);
202
220
  expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]);
221
+ // The unjailed lint's variable is present only when there is something
222
+ // to lint (task 4.7). A jailed run carries nothing.
223
+ expect(env.CELILO_HOOK_MOUNT_SET).toBeUndefined();
203
224
  } finally {
204
225
  delete process.env.CELILO_TEST_SECRET;
205
226
  }
@@ -211,7 +232,7 @@ describe('the child environment is an allow-list', () => {
211
232
  try {
212
233
  // Absent, not the string "undefined" — which is what a naive copy
213
234
  // produces and what a shell then happily uses as a timezone.
214
- expect('TZ' in hookChildEnv('/tmp/s')).toBe(false);
235
+ expect('TZ' in hookChildEnv('/tmp/s', '/tmp/r', undefined)).toBe(false);
215
236
  } finally {
216
237
  if (saved !== undefined) process.env.TZ = saved;
217
238
  }
@@ -245,3 +266,44 @@ describe('hook process boundary — hello-trespass gate', () => {
245
266
  expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false);
246
267
  });
247
268
  });
269
+
270
+ const availability = detectJailBackend();
271
+ const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
272
+
273
+ describe.skipIf(!jailed)(
274
+ `stage 2: the same hook, jailed (backend: ${availability.backend})`,
275
+ () => {
276
+ // Task 4.12's platform half that a Mac can run. The e2e stage
277
+ // (`e2e/tests/hook-jail-trespass.test.ts`) is the other one, because
278
+ // bubblewrap needs a Linux kernel and a container to hold it.
279
+ //
280
+ // The SAME fixture, run twice, is the whole design of this gate: the stage 1
281
+ // block above asserts these two trespasses SUCCEED, and this one asserts the
282
+ // jail refuses them. Neither reading is available from one run.
283
+ test('trespasses 1 and 2 are refused, and the environment is still clean', async () => {
284
+ const { report, lines } = await runTrespass(true);
285
+ console.log(['', 'hello-trespass (jailed):', ...lines.slice(1)].join('\n'));
286
+
287
+ // What stage 2 claims: celilo's data directory is not bound, so the master
288
+ // key is unreachable. `<store>` is not bound either, so a sibling is too.
289
+ expect(report.master_key.succeeded).toBe(false);
290
+ expect(report.sibling_write.succeeded).toBe(false);
291
+
292
+ // Unreachability, never an errno. bubblewrap removes the path and reports
293
+ // ENOENT; sandbox-exec denies it and reports EPERM. Pinning either one
294
+ // would go red on a platform whose jail works perfectly (D9).
295
+ expect(report.master_key.detail).toMatch(/ENOENT|EPERM/);
296
+
297
+ // Stage 1's claim has not regressed on the way to stage 2.
298
+ expect(report.sensitive_env).toEqual([]);
299
+ }, 120_000);
300
+
301
+ // Trespass 3 is deliberately NOT asserted here, and the reason is a property
302
+ // of this fixture rather than of the jail. `jailRequest` binds `~/.ssh` from
303
+ // `os.homedir()`, which on macOS reads the password database and ignores the
304
+ // `HOME` this file redirects — so the hook looks in the scratch home and the
305
+ // jail bound the real one, and the row reports a refusal that says nothing
306
+ // about stage 3. The e2e stage asserts it, where the two agree. Filed as
307
+ // celilo#1211.
308
+ },
309
+ );
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The jailed browser launch path starts Chromium with `--no-sandbox` (task
3
+ * 4.10 interim, celilo#1215).
4
+ *
5
+ * The behavioural proof lives in `hook-jail-toolchain-reach.test.ts`: with its
6
+ * own sandbox on, Chromium does not start inside the bubblewrap jail; with
7
+ * `--no-sandbox` it renders. But that run needs `CELILO_PROBE_BROWSER`, so on
8
+ * an ordinary host it reports "no browser supplied" and nothing would fail if
9
+ * the flag were dropped from the launcher. This suite is the pin that always
10
+ * runs: it asserts the launch args the probe hook actually uses, and it fails
11
+ * the moment `--no-sandbox` leaves the launch path.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+ import { JAILED_LAUNCH_ARGS, SANDBOXED_LAUNCH_ARGS } from './test-fixtures/jail-toolchain-hook';
16
+
17
+ describe('jailed browser launch flags (4.10 interim, celilo#1215)', () => {
18
+ test('the jailed launch path carries --no-sandbox', () => {
19
+ expect(JAILED_LAUNCH_ARGS).toContain('--no-sandbox');
20
+ });
21
+
22
+ test('the experiment arm keeps Chromium sandboxed, so the comparison stays controlled', () => {
23
+ expect(SANDBOXED_LAUNCH_ARGS).not.toContain('--no-sandbox');
24
+ });
25
+
26
+ test('the two arms differ by the sandbox flag alone', () => {
27
+ const onlyInJailed = JAILED_LAUNCH_ARGS.filter((arg) => !SANDBOXED_LAUNCH_ARGS.includes(arg));
28
+ const onlyInSandboxed = SANDBOXED_LAUNCH_ARGS.filter(
29
+ (arg) => !JAILED_LAUNCH_ARGS.includes(arg),
30
+ );
31
+ expect(onlyInJailed).toEqual(['--no-sandbox']);
32
+ expect(onlyInSandboxed).toEqual([]);
33
+ });
34
+ });
@@ -18,6 +18,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import {
21
+ JAIL_OFF_REASON,
21
22
  type JailAvailability,
22
23
  type JailPlan,
23
24
  jailModeStorePath,
@@ -227,6 +228,37 @@ describe('the three policies', () => {
227
228
  });
228
229
  });
229
230
 
231
+ describe('auto defers on sandbox-exec until D14 exists (ce-29z)', () => {
232
+ // The backend hand-built here is one no probe yields yet — task 4.8 owns
233
+ // the platform probe that reports it. The policy, though, is decided NOW:
234
+ // sandbox-exec denies (EPERM) a hook write to an undeclared host path where
235
+ // bubblewrap masks it into its private tmpfs, so a macOS auto jail reddens
236
+ // the full suite for fixtures main's Linux CI passes. Measured, ce-29z.
237
+ const SANDBOX_EXEC: JailAvailability = { backend: 'sandbox-exec' };
238
+
239
+ test("'auto' runs the hook unjailed and says why", () => {
240
+ const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'auto', allPresent);
241
+ expect(p.mode).toBe('unjailed');
242
+ expect(p.cmd).toEqual(CMD);
243
+ expect(p.backend).toBe('sandbox-exec');
244
+ expect(p.reason).toMatch(/D14/);
245
+ expect(p.reason).toMatch(/CELILO_HOOK_JAIL=required/);
246
+ });
247
+
248
+ test("'required' is the operator's explicit act and is NOT deferred", () => {
249
+ // Mode only, not argv shape: the sandbox-exec spawn arm is task 4.8's.
250
+ // This assertion survives that landing; an argv assertion would not.
251
+ const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'required', allPresent);
252
+ expect(p.mode).toBe('jailed');
253
+ });
254
+
255
+ test("'off' still declines a jail that IS available", () => {
256
+ const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'off', allPresent);
257
+ expect(p.mode).toBe('unjailed');
258
+ expect(p.reason).toBe(JAIL_OFF_REASON);
259
+ });
260
+ });
261
+
230
262
  describe('jailPolicy reads the operator’s switch', () => {
231
263
  const withEnv = <T>(value: string | undefined, fn: () => T): T => {
232
264
  const saved = process.env.CELILO_HOOK_JAIL;
@@ -367,4 +399,64 @@ describe('the mode is recorded state, not a log line (design D8)', () => {
367
399
  if (saved !== undefined) process.env.CELILO_HOOK_JAIL_MODE_PATH = saved;
368
400
  }
369
401
  });
402
+
403
+ // The write overwrites the previous record, and the self-monitor (task 4.4)
404
+ // reads the file on a sweep long after `recordJailMode`'s return value is
405
+ // gone. `lastJailed` is what keeps the transition readable.
406
+ test('an unjailed record remembers the jailed record it replaced (task 4.4)', () => {
407
+ withStore(() => {
408
+ recordJailMode(jailed);
409
+ const jailedAt = readJailMode()?.recordedAt;
410
+ recordJailMode(unjailed);
411
+ const record = readJailMode();
412
+ expect(record?.lastJailed?.backend).toBe('bubblewrap');
413
+ expect(record?.lastJailed?.recordedAt).toBe(jailedAt as string);
414
+ });
415
+ });
416
+
417
+ test('re-jailing clears the memory, so a later regression dates from the new jailed record', () => {
418
+ withStore(() => {
419
+ recordJailMode(jailed);
420
+ recordJailMode(unjailed);
421
+ recordJailMode(jailed);
422
+ expect(readJailMode()?.lastJailed).toBeUndefined();
423
+ });
424
+ });
425
+
426
+ test('an unjailed-to-unjailed rewrite carries the memory along', () => {
427
+ withStore(() => {
428
+ // CELILO_HOOK_JAIL=off on a host whose backend still works: mode
429
+ // unjailed, backend bubblewrap. The regression memory must survive the
430
+ // operator then losing the backend too.
431
+ const off: JailPlan = {
432
+ cmd: CMD,
433
+ mode: 'unjailed',
434
+ backend: 'bubblewrap',
435
+ reason: 'CELILO_HOOK_JAIL=off',
436
+ skipped: [],
437
+ };
438
+ recordJailMode(jailed);
439
+ const jailedAt = readJailMode()?.recordedAt;
440
+ recordJailMode(off);
441
+ recordJailMode(unjailed);
442
+ expect(readJailMode()?.lastJailed?.recordedAt).toBe(jailedAt as string);
443
+ });
444
+ });
445
+
446
+ test("another host's jailed record is a move, not a transition", () => {
447
+ withStore((path) => {
448
+ mkdirSync(join(path, '..'), { recursive: true });
449
+ writeFileSync(
450
+ path,
451
+ JSON.stringify({
452
+ mode: 'jailed',
453
+ backend: 'bubblewrap',
454
+ host: 'some-other-box',
455
+ recordedAt: '2026-01-01T00:00:00.000Z',
456
+ }),
457
+ );
458
+ recordJailMode(unjailed);
459
+ expect(readJailMode()?.lastJailed).toBeUndefined();
460
+ });
461
+ });
370
462
  });