@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,224 @@
1
+ /**
2
+ * What TOOLING the jail leaves a hook (task 4.13).
3
+ *
4
+ * `hook-jail-unreachability.test.ts` asserts D9's filesystem claims and they
5
+ * hold. This suite asks the question D9's table never answers, and the answer
6
+ * turns out to be the reason task 4.13 exists: **the fleet's real hooks shell
7
+ * out, and the jail as derived today has no shell.**
8
+ *
9
+ * `execSync` spawns `/bin/sh -c`. `remote.ts` — the single seam every remote
10
+ * primitive routes through — builds an `ssh <user>@<host> <cmd>` string and
11
+ * hands it to exactly that. The derivation binds `/usr/lib`, `/lib`, `/lib64`
12
+ * and `/etc/ssl`, and nothing else outside the module's own tree. No `/bin`,
13
+ * no `/usr/bin`, no `/etc/resolv.conf`.
14
+ *
15
+ * That is measured here rather than argued, because reading the derivation is
16
+ * what produced the belief that stage 2 was complete. The mount set is correct
17
+ * about every path it names. It is silent about the ones a hook needs to run
18
+ * another program at all, and silence and correctness look identical from the
19
+ * outside.
20
+ *
21
+ * ⚠️ **These tests assert the CURRENT reach, including the failures.** They are
22
+ * not a wish list. When stage 2 gains a shell — or stage 3 brokers the remote
23
+ * calls and hooks stop needing one — the expectations here change with it, and
24
+ * the diff that changes them is where somebody states which of those happened.
25
+ * A suite that asserted the desired end state would be red for the whole of
26
+ * stage 2 and would teach nobody anything.
27
+ *
28
+ * **Needs a real jail and skips without one, loudly**, for the reason its
29
+ * neighbour gives: a check that cannot reach its subject returns a confident
30
+ * answer about nothing.
31
+ */
32
+
33
+ import { describe, expect, test } from 'bun:test';
34
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
35
+ import { tmpdir } from 'node:os';
36
+ import { dirname, join, resolve } from 'node:path';
37
+ import { executeHookScript } from './executor';
38
+ import { detectJailBackend } from './jail';
39
+ import { createCapturingLogger } from './logger';
40
+ import type { HookContext } from './types';
41
+
42
+ const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts');
43
+
44
+ interface Probe {
45
+ succeeded: boolean;
46
+ detail: string;
47
+ }
48
+
49
+ interface Reach {
50
+ shell: Probe;
51
+ exec_without_shell: Probe;
52
+ ssh: Probe;
53
+ ansible: Probe;
54
+ resolv_conf: Probe;
55
+ dns_lookup: Probe;
56
+ nsswitch: Probe;
57
+ hosts_file: Probe;
58
+ playwright_cache: Probe;
59
+ celilo_browser_root: Probe;
60
+ data_dir_sibling: Probe;
61
+ dynamic_loader: Probe;
62
+ browser_binary_present: Probe;
63
+ browser_sandboxed: Probe;
64
+ browser_no_sandbox: Probe;
65
+ bwrap_present: Probe;
66
+ }
67
+
68
+ /**
69
+ * A real browser to launch inside the jail, or nothing.
70
+ *
71
+ * Task 4.10's question cannot be answered without one, and most hosts have
72
+ * none. `CELILO_PROBE_BROWSER` names it for the run that does — the Docker
73
+ * image this suite's finding was measured in. Absent, the two browser probes
74
+ * report "no browser supplied" and the run says so out loud rather than
75
+ * reporting an ABSENT that means "not tested".
76
+ */
77
+ const PROBE_BROWSER = process.env.CELILO_PROBE_BROWSER ?? '';
78
+
79
+ /**
80
+ * The browser's directory, refused if handing it over would defeat this
81
+ * suite's own `bwrap` guard.
82
+ *
83
+ * Binding the browser's directory as a path input is how a browser reaches the
84
+ * jail here. Point `CELILO_PROBE_BROWSER` at `/usr/bin/chromium` and that
85
+ * directory is `/usr/bin`, which carries `bwrap` in with it — the guard below
86
+ * then fails for a reason that is the harness's fault, not the derivation's.
87
+ * Measured: it happened on the first run of this file.
88
+ *
89
+ * Debian's `/usr/bin/chromium` is a `#!/bin/sh` wrapper anyway and cannot exec
90
+ * in a jail with no shell. The real ELF is under `/usr/lib`, which the
91
+ * derivation already binds, so the honest browser to name is that one.
92
+ */
93
+ function probeBrowserDir(): string {
94
+ const dir = dirname(PROBE_BROWSER);
95
+ if (existsSync(join(dir, 'bwrap'))) {
96
+ throw new Error(
97
+ `CELILO_PROBE_BROWSER=${PROBE_BROWSER} would bind ${dir}, which holds bwrap and would defeat the guard this suite exists to keep. Name the browser's real ELF (Debian: /usr/lib/chromium/chromium), not the /usr/bin wrapper.`,
98
+ );
99
+ }
100
+ return dir;
101
+ }
102
+
103
+ const availability = detectJailBackend();
104
+ const jailed = availability.backend !== 'none';
105
+
106
+ /** Run the probe hook against a module tree laid out the way celilo lays one out. */
107
+ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
108
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-reach-'));
109
+ const modulePath = join(scratch, 'modules', 'jail-reach');
110
+ const stateDir = join(modulePath, 'state');
111
+ const screenshotDir = join(modulePath, 'screenshots', 'run');
112
+ mkdirSync(join(modulePath, 'scripts'), { recursive: true });
113
+ mkdirSync(stateDir, { recursive: true });
114
+ mkdirSync(screenshotDir, { recursive: true });
115
+
116
+ const { logger } = createCapturingLogger();
117
+ const context: HookContext = {
118
+ config: PROBE_BROWSER ? { browser_executable: PROBE_BROWSER } : {},
119
+ secrets: {},
120
+ systems: [],
121
+ logger,
122
+ debug: false,
123
+ screenshotDir,
124
+ stateDir,
125
+ capabilities: {},
126
+ };
127
+
128
+ const reach = (await executeHookScript(PROBE_HOOK, context, {
129
+ timeoutMs: 60_000,
130
+ idleTimeoutMs: 60_000,
131
+ jail: {
132
+ modulePath,
133
+ // The browser reaches the jail as a DECLARED PATH INPUT, which is the
134
+ // mechanism 4.10's fix would use. Binding it by editing the derivation
135
+ // and then asking the derivation whether it is bound would prove
136
+ // nothing.
137
+ pathInputs: PROBE_BROWSER
138
+ ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
139
+ : [],
140
+ },
141
+ })) as unknown as Reach;
142
+
143
+ return { reach, cleanup: () => rmSync(scratch, { recursive: true, force: true }) };
144
+ }
145
+
146
+ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => {
147
+ // 30s, not bun's default 5s: the DNS probe deliberately waits for an answer
148
+ // that never comes, and a runner timeout would report as a suite failure
149
+ // rather than as the measurement it is.
150
+ test('measures the reach', async () => {
151
+ const { reach, cleanup } = await measureReach();
152
+ try {
153
+ // Printed unconditionally, and to stderr because bun swallows a passing
154
+ // test's stdout. This suite's value is the table, and a run that only
155
+ // says "1 pass" has thrown the finding away.
156
+ for (const [name, probe] of Object.entries(reach)) {
157
+ console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`);
158
+ }
159
+
160
+ // ── The finding ────────────────────────────────────────────────────
161
+ // No shell, so `execSync` cannot run and neither can any hook that uses
162
+ // it. 22 module script files import `node:child_process`.
163
+ expect(reach.shell.succeeded).toBe(false);
164
+ expect(reach.exec_without_shell.succeeded).toBe(false);
165
+
166
+ // `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts`
167
+ // keeps working. There is no `ssh` to hand that key to.
168
+ expect(reach.ssh.succeeded).toBe(false);
169
+ expect(reach.ansible.succeeded).toBe(false);
170
+
171
+ // Nothing binds the resolver's configuration.
172
+ expect(reach.resolv_conf.succeeded).toBe(false);
173
+
174
+ // ── Task 4.10 ──────────────────────────────────────────────────────
175
+ // `~/.cache/ms-playwright` is the path task 4.10 names and it is bound
176
+ // by nothing, deliberately: `managed-browser-runtime` moved the browser
177
+ // into a celilo-owned tree, so binding the cache would bind a directory
178
+ // nothing launches from.
179
+ expect(reach.playwright_cache.succeeded).toBe(false);
180
+
181
+ // Binding BROWSER_ROOT must not expose what sits beside it. This one IS
182
+ // asserted unconditionally: `master.key` is unreadable whether or not
183
+ // the browser directory exists on this host, so unlike the row below
184
+ // there is no configuration in which this passes vacuously.
185
+ expect(reach.data_dir_sibling.succeeded).toBe(false);
186
+
187
+ // `BROWSER_ROOT` is NOT asserted here, and the reason is worth stating
188
+ // because asserting it would be the trap this file exists to avoid.
189
+ // `deriveMountSet` now emits the row, but `planJailedSpawn` drops any
190
+ // row whose source is missing — so on a host with no provisioned
191
+ // browser this probe reports ABSENT for a reason that has nothing to do
192
+ // with the mount set, and an assertion either way would be measuring
193
+ // the host rather than the derivation. The row itself is asserted
194
+ // hermetically in `mount-set.test.ts`, where it can be.
195
+
196
+ // The nesting question, asserted only on a run that supplied a browser.
197
+ // Measured 2026-08-28 in `oven/bun:latest` on aarch64 with Debian's
198
+ // chromium: with its own sandbox Chromium will not start inside the
199
+ // jail, and `--no-sandbox` renders. That is task 4.10's prediction
200
+ // confirmed, and it says what the fix has to include.
201
+ if (PROBE_BROWSER) {
202
+ expect(reach.browser_binary_present.succeeded).toBe(true);
203
+ expect(reach.browser_sandboxed.succeeded).toBe(false);
204
+ expect(reach.browser_no_sandbox.succeeded).toBe(true);
205
+ }
206
+
207
+ // ── The guard ──────────────────────────────────────────────────────
208
+ // Opposite direction, and it must never flip. D9 predicts the exact
209
+ // edit that would flip it: someone reads the failures above, binds
210
+ // `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden`
211
+ // compares whole paths, so a bind of the DIRECTORY passes that filter.
212
+ expect(reach.bwrap_present.succeeded).toBe(false);
213
+ } finally {
214
+ cleanup();
215
+ }
216
+ }, 30_000);
217
+ });
218
+
219
+ describe.skipIf(jailed)('no jail on this host', () => {
220
+ test('says so rather than passing quietly', () => {
221
+ console.log(`hook jail unavailable, so 4.13's reach was NOT measured: ${availability.reason}`);
222
+ expect(availability.backend).toBe('none');
223
+ });
224
+ });
@@ -27,8 +27,11 @@
27
27
 
28
28
  import { describe, expect, test } from 'bun:test';
29
29
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
- import { tmpdir } from 'node:os';
30
+ import { homedir, tmpdir } from 'node:os';
31
31
  import { join, resolve } from 'node:path';
32
+
33
+ /** Uniquely named so it never collides with a real key on a jailed dev box. */
34
+ const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
32
35
  import { executeHookScript } from './executor';
33
36
  import { detectJailBackend } from './jail';
34
37
  import { createCapturingLogger } from './logger';
@@ -50,6 +53,7 @@ interface Rig {
50
53
  sibling_write: Probe;
51
54
  state_write: Probe;
52
55
  staged_write: Probe;
56
+ ssh_key: Probe;
53
57
  };
54
58
  stagedInput: string;
55
59
  siblingFile: string;
@@ -85,6 +89,20 @@ async function runProbe(): Promise<Rig> {
85
89
  // produces. This is the path the tmpfs would erase.
86
90
  const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-jail-staged-'));
87
91
 
92
+ // A throwaway SSH key at the exact path the pre-stage-3 jail bound —
93
+ // `homedir()/.ssh` — so the flip (task 5.5) is real: the red baseline
94
+ // binds this directory and the probe reads the key, this branch binds no
95
+ // `.ssh` at all and the read gives ENOENT. Guarded to jailed hosts only
96
+ // (this whole suite is `skipIf(!jailed)`), which on a Mac is never — so
97
+ // the operator's real key is never touched on a dev box; on a jailed CI
98
+ // box homedir is an ephemeral container root. Uniquely named and removed
99
+ // in cleanup regardless.
100
+ const sshDir = join(homedir(), '.ssh');
101
+ const plantedKey = join(sshDir, PLANTED_KEY_NAME);
102
+ const createdSshDir = !existsSync(sshDir);
103
+ mkdirSync(sshDir, { recursive: true });
104
+ writeFileSync(plantedKey, 'not-a-real-key\n');
105
+
88
106
  const { logger } = createCapturingLogger();
89
107
  const context: HookContext = {
90
108
  config: {
@@ -117,6 +135,8 @@ async function runProbe(): Promise<Rig> {
117
135
  cleanup: () => {
118
136
  rmSync(scratch, { recursive: true, force: true });
119
137
  rmSync(stagedInput, { recursive: true, force: true });
138
+ rmSync(plantedKey, { force: true });
139
+ if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
120
140
  },
121
141
  };
122
142
  }
@@ -125,7 +145,7 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
125
145
  test('a hook cannot reach celilo’s master key, or a sibling module', async () => {
126
146
  const rig = await runProbe();
127
147
  try {
128
- const { planted_secret, sibling_write, state_write, staged_write } = rig.outputs;
148
+ const { planted_secret, sibling_write, state_write, staged_write, ssh_key } = rig.outputs;
129
149
  console.log(['', 'jail probe:', JSON.stringify(rig.outputs, null, 2)].join('\n'));
130
150
 
131
151
  // Unreachability, not an errno. Under bubblewrap the message is ENOENT
@@ -136,6 +156,12 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
136
156
  // And the parent's own view: the write did not land by another route.
137
157
  expect(existsSync(rig.siblingFile)).toBe(false);
138
158
 
159
+ // Stage 3 (design D12, task 5.5): the SSH credential is not bound, so
160
+ // a jailed hook cannot authenticate anywhere by hand. This is the
161
+ // recurrence-gate row that FLIPPED when the `~/.ssh` mount was removed
162
+ // — it read `succeeded: true` for the whole of stage 2.
163
+ expect(ssh_key.succeeded).toBe(false);
164
+
139
165
  // The carve-out. `state/` is inside the read-only module tree and is
140
166
  // bound read-write on top of it; if the ordering were wrong this is the
141
167
  // assertion that would catch it.
@@ -31,6 +31,28 @@ export const HOOK_PROTOCOL_VERSION = 1;
31
31
  export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
32
32
  /** Environment variable carrying the parent's protocol version to the child. */
33
33
  export const HOOK_PROTOCOL_VERSION_ENV = 'CELILO_HOOK_PROTOCOL_VERSION';
34
+ /**
35
+ * Environment variable carrying the remote-ops broker's socket path (stage 3,
36
+ * design D12). The ASKING half lives in `@celilo/capabilities`' remote
37
+ * primitives, which a module bundles — so that side names this variable as a
38
+ * string literal of its own (`packages/capabilities/src/remote.ts`), and the
39
+ * two must agree the way the socket framing must.
40
+ */
41
+ export const HOOK_REMOTE_SOCKET_ENV = 'CELILO_HOOK_REMOTE_SOCKET';
42
+ /**
43
+ * Environment variable carrying the derived mount set to the child, as JSON
44
+ * (task 4.7, the unjailed advisory lint).
45
+ *
46
+ * Set ONLY when the run is unjailed AND a mount set exists — its presence is
47
+ * the shim's signal to install the lint, so a jailed run carries nothing. The
48
+ * environment is the right channel rather than a protocol frame because this
49
+ * is spawn-time configuration of the shim, exactly like the two socket paths
50
+ * above, and because the value is finalised in the same planning step that
51
+ * picks the spawn command. The child validates it with `MountSetWireSchema`
52
+ * before use; it is written by celilo and read by celilo, but it crosses a
53
+ * process boundary and gets the same treatment as any other payload.
54
+ */
55
+ export const HOOK_MOUNT_SET_ENV = 'CELILO_HOOK_MOUNT_SET';
34
56
 
35
57
  /**
36
58
  * An error crossing the boundary.
@@ -111,6 +133,28 @@ export const ContextFrameSchema = z.object({
111
133
  context: z.record(z.unknown()),
112
134
  });
113
135
 
136
+ /**
137
+ * One row of the mount set, on the wire.
138
+ *
139
+ * Structural twin of `mount-set.ts`'s `MountEntry`. Duplicated as a schema
140
+ * rather than imported because this file is the wire contract and the wire
141
+ * must not grow a compile-time dependency on the derivation's internals —
142
+ * `hook-protocol.ts`'s docblock records who may import whom.
143
+ */
144
+ export const MountEntrySchema = z.object({
145
+ path: z.string(),
146
+ mode: z.enum(['ro', 'rw', 'tmpfs']),
147
+ reason: z.string(),
148
+ });
149
+
150
+ /** The derived mount set, on the wire. Structural twin of `MountSet`. */
151
+ export const MountSetWireSchema = z.object({
152
+ entries: z.array(MountEntrySchema),
153
+ chdir: z.string(),
154
+ });
155
+
156
+ export type MountSetWire = z.infer<typeof MountSetWireSchema>;
157
+
114
158
  /**
115
159
  * The capability shape descriptor (design D2).
116
160
  *
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The runner shim's entry point — the first file the hook process executes.
3
+ *
4
+ * **It exists to fix the evaluation order of one import.** The advisory lint
5
+ * (`unjailed-lint.ts`) must have its `node:fs` wrappers in place before any
6
+ * other module in this process ESM-loads `node:fs`, because Bun resolves a
7
+ * builtin's named ESM exports once — the first import freezes them. The
8
+ * runner shim itself imports `@celilo/capabilities`, whose graph reaches
9
+ * `node:fs`, so by the time the shim's own body could install anything, the
10
+ * facade is already frozen and the lint is silently disarmed (measured while
11
+ * landing task 4.7; `unjailed-lint.test.ts` goes red without this file).
12
+ *
13
+ * So: install first (the module also self-installs at evaluation; the guard
14
+ * makes whichever lands first correct), and only then hand off to the real
15
+ * runner. Nothing else belongs in this file, and nothing here may import a
16
+ * module whose graph reaches `node:fs` — that is the whole point of it.
17
+ */
18
+
19
+ import { installUnjailedLintIfUnjailed } from './unjailed-lint';
20
+
21
+ installUnjailedLintIfUnjailed();
22
+
23
+ await import('./hook-runner');
@@ -27,6 +27,12 @@ import {
27
27
  versionMismatch,
28
28
  } from './hook-protocol';
29
29
  import type { HookContext, HookLogger } from './types';
30
+ import { forwardLintWarnings } from './unjailed-lint';
31
+
32
+ // The advisory lint was installed before this module was even loaded — the
33
+ // executor spawns `hook-runner-entry.ts`, whose first statement forces
34
+ // `unjailed-lint`'s evaluation (see that module's docblock for why the order
35
+ // is load-bearing there and irrelevant here).
30
36
 
31
37
  const socketPath = process.env[HOOK_SOCKET_ENV];
32
38
  if (!socketPath) {
@@ -123,6 +129,10 @@ async function runHook(): Promise<void> {
123
129
  if (!(field in contextData)) throw new Error(`Hook context is missing '${field}'.`);
124
130
  }
125
131
 
132
+ // Before the import, not after — the lint's warnings flow through the
133
+ // hook's logger from here on (anything earlier was buffered).
134
+ forwardLintWarnings(logger.warn);
135
+
126
136
  const context = {
127
137
  ...contextData,
128
138
  logger,
@@ -178,12 +178,13 @@ describe('the child environment is an allow-list', () => {
178
178
  // complement: the six things that MUST get through, because a hook with no
179
179
  // `PATH` cannot spawn anything and one with no `HOME` breaks more than it
180
180
  // protects.
181
- test('forwards the allow-list and the channel, and nothing else', () => {
181
+ test('forwards the allow-list and the channels, and nothing else', () => {
182
182
  process.env.CELILO_TEST_SECRET = 'must-not-cross';
183
183
  try {
184
- const env = hookChildEnv('/tmp/celilo-hook-x/s');
184
+ const env = hookChildEnv('/tmp/celilo-hook-x/s', '/tmp/celilo-hook-x/r', undefined);
185
185
 
186
186
  expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
187
+ expect(env.CELILO_HOOK_REMOTE_SOCKET).toBe('/tmp/celilo-hook-x/r');
187
188
  expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
188
189
  expect(env.PATH).toBe(process.env.PATH as string);
189
190
  expect(env.HOME).toBe(process.env.HOME as string);
@@ -197,9 +198,14 @@ describe('the child environment is an allow-list', () => {
197
198
  'TMPDIR',
198
199
  'CELILO_DEBUG',
199
200
  'CELILO_HOOK_SOCKET',
201
+ 'CELILO_HOOK_REMOTE_SOCKET',
200
202
  'CELILO_HOOK_PROTOCOL_VERSION',
203
+ 'CELILO_HOOK_MOUNT_SET',
201
204
  ]);
202
205
  expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]);
206
+ // The unjailed lint's variable is present only when there is something
207
+ // to lint (task 4.7). A jailed run carries nothing.
208
+ expect(env.CELILO_HOOK_MOUNT_SET).toBeUndefined();
203
209
  } finally {
204
210
  delete process.env.CELILO_TEST_SECRET;
205
211
  }
@@ -211,7 +217,7 @@ describe('the child environment is an allow-list', () => {
211
217
  try {
212
218
  // Absent, not the string "undefined" — which is what a naive copy
213
219
  // produces and what a shell then happily uses as a timezone.
214
- expect('TZ' in hookChildEnv('/tmp/s')).toBe(false);
220
+ expect('TZ' in hookChildEnv('/tmp/s', '/tmp/r', undefined)).toBe(false);
215
221
  } finally {
216
222
  if (saved !== undefined) process.env.TZ = saved;
217
223
  }
@@ -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
  });