@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
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The remote-ops broker — stage 3's answering half (hook-process-boundary,
3
+ * design D12).
4
+ *
5
+ * A hook holds no SSH credential: the jail does not bind `~/.ssh`, so a
6
+ * hand-built `ssh` cannot authenticate anywhere. `@celilo/capabilities`'
7
+ * remote primitives detect the hook environment and send each operation here
8
+ * as a STRUCTURED request. This side holds the key, checks the target against
9
+ * the policy the caller injected, and runs the real primitive.
10
+ *
11
+ * Structured, never a shell string: a broker that accepted a command line
12
+ * from the jail would run arbitrary code as celilo OUTSIDE the jail, which is
13
+ * the exact thing the boundary exists to prevent. The same reasoning refuses
14
+ * `opts.env` (it never crosses; the asking half says so loudly) and confines
15
+ * the stream primitives' LOCAL paths to the directories this run already owns
16
+ * — without that check `streamBackup` would be a write-anything-as-celilo
17
+ * oracle aimed at `master.key`.
18
+ *
19
+ * **Attribution (D12).** Every operation on this channel belongs to the
20
+ * module whose hook is running — the policy is CONSTRUCTED for that module by
21
+ * the invoking service. A capability provider's internal transport (the
22
+ * `public_web` upload) does not pass through here at all: providers run in
23
+ * celilo's own process (design D10), and the one hand-built-ssh provider call
24
+ * site is being replaced by an Ansible converge under
25
+ * `openspec/changes/capability-owned-tables` (celilo#1014). The same
26
+ * `checkTarget` contract serves a provider-owned policy if provider transport
27
+ * is ever brokered — the owner is a construction argument, never inferred
28
+ * from a caller.
29
+ *
30
+ * Unlike the capability socket (one connection, one hook), this socket
31
+ * answers MANY short-lived connections: the asking half spawns a client per
32
+ * call, one request line in, one response line out.
33
+ *
34
+ * **A remote operation blocks celilo's event loop for its duration** — the
35
+ * primitives are execSync-backed, bounded by each call's own `timeoutMs`.
36
+ * That is not new exposure: before the process boundary every hook ran its
37
+ * ssh on this same loop, and provider transport (design D10) still does.
38
+ * The asking side is blocked in spawnSync anyway, so nothing concurrent is
39
+ * lost; timers (the hook's total/idle kill) fire late by at most one op.
40
+ *
41
+ * Execution function (Rule 10.1) — owns the socket and performs the calls.
42
+ */
43
+
44
+ import { lstatSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
45
+ import { type Server, createServer } from 'node:net';
46
+ import { tmpdir } from 'node:os';
47
+ import { basename, dirname, join, resolve, sep } from 'node:path';
48
+ import {
49
+ type RemoteTarget,
50
+ type RunResult,
51
+ type Runner,
52
+ execRunner,
53
+ installAuthorizedKey,
54
+ remoteExec,
55
+ streamBackup,
56
+ streamRestore,
57
+ } from '@celilo/capabilities';
58
+ import { z } from 'zod';
59
+ import { createLineReader } from './hook-protocol';
60
+ import type { HookLogger } from './types';
61
+
62
+ /** The socket's filename beside the capability socket ('s') in the run dir. */
63
+ const REMOTE_SOCKET_NAME = 'r';
64
+
65
+ /** Refuse a request line larger than this rather than buffering it. */
66
+ const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
67
+
68
+ const TargetSchema = z.object({
69
+ ipv4_address: z.string().min(1),
70
+ user: z.string().optional(),
71
+ port: z.number().int().optional(),
72
+ /**
73
+ * The module's OWN key, as content (task 5.4). The asking half reads the
74
+ * file inside the jail, so a hook can only send bytes it may already read
75
+ * — never a path for celilo to open on its behalf.
76
+ */
77
+ identityContent: z.string().optional(),
78
+ });
79
+
80
+ const OptsSchema = z
81
+ .object({
82
+ timeoutMs: z.number().optional(),
83
+ input: z.string().optional(),
84
+ })
85
+ .optional();
86
+
87
+ const RemoteRequestSchema = z.discriminatedUnion('op', [
88
+ z.object({
89
+ op: z.literal('remoteExec'),
90
+ target: TargetSchema,
91
+ command: z.string(),
92
+ opts: OptsSchema,
93
+ }),
94
+ z.object({
95
+ op: z.literal('streamBackup'),
96
+ target: TargetSchema,
97
+ producerCommand: z.string(),
98
+ localPath: z.string(),
99
+ opts: OptsSchema,
100
+ }),
101
+ z.object({
102
+ op: z.literal('streamRestore'),
103
+ target: TargetSchema,
104
+ localPath: z.string(),
105
+ consumerCommand: z.string(),
106
+ opts: OptsSchema,
107
+ }),
108
+ z.object({
109
+ op: z.literal('installAuthorizedKey'),
110
+ target: TargetSchema,
111
+ password: z.string(),
112
+ opts: OptsSchema,
113
+ }),
114
+ z.object({ op: z.literal('checkTarget'), target: TargetSchema }),
115
+ ]);
116
+
117
+ type RemoteRequest = z.infer<typeof RemoteRequestSchema>;
118
+ type BridgeTarget = z.infer<typeof TargetSchema>;
119
+
120
+ export type RemoteCheckResult = { allowed: true } | { allowed: false; message: string };
121
+
122
+ /**
123
+ * D12's reachability policy, built by the invoking service for the module the
124
+ * run belongs to (`services/remote-access.ts`). The broker only enforces it.
125
+ */
126
+ export interface RemoteAccessPolicy {
127
+ /** The module every operation on this channel is attributed to. */
128
+ moduleId: string;
129
+ /**
130
+ * May this module reach `target`? Called per operation, so it reads fresh
131
+ * state. `hasOwnCredential` is true when the request carries the module's
132
+ * own key or password rather than riding celilo's fleet key.
133
+ */
134
+ checkTarget(
135
+ target: { ipv4_address: string; user?: string },
136
+ hasOwnCredential: boolean,
137
+ ): RemoteCheckResult;
138
+ }
139
+
140
+ export interface RemoteBrokerOptions {
141
+ /** Directory holding the capability socket; the remote socket sits beside it. */
142
+ socketDir: string;
143
+ /**
144
+ * Absent means DENY: a run started without a policy refuses every
145
+ * operation, naming the gap. Fail closed, never open (Rule 6.4).
146
+ */
147
+ policy?: RemoteAccessPolicy;
148
+ /** Local roots `streamRestore` may read from. Realpath'd here once. */
149
+ readableRoots: readonly string[];
150
+ /** Local roots `streamBackup` may write into. Realpath'd here once. */
151
+ writableRoots: readonly string[];
152
+ logger: HookLogger;
153
+ /** Every operation feeds the idle tracker — remote work is real work. */
154
+ onActivity: () => void;
155
+ /** Injectable transport for tests (default: the real execRunner). */
156
+ runner?: Runner;
157
+ }
158
+
159
+ export interface RemoteBroker {
160
+ /** Unix socket path, handed to the child as CELILO_HOOK_REMOTE_SOCKET. */
161
+ socketPath: string;
162
+ /** Refuse further operations. Idempotent. Called on kill and on cleanup. */
163
+ stop(): void;
164
+ /** Close the listener. The socket file goes with the run directory. */
165
+ close(): void;
166
+ }
167
+
168
+ /** Serve remote operations for one hook run. */
169
+ export async function startRemoteBroker(options: RemoteBrokerOptions): Promise<RemoteBroker> {
170
+ const socketPath = join(options.socketDir, REMOTE_SOCKET_NAME);
171
+ const runner = options.runner ?? execRunner;
172
+ const readableRoots = options.readableRoots.map(realpathOrSelf);
173
+ const writableRoots = options.writableRoots.map(realpathOrSelf);
174
+ let stopped = false;
175
+
176
+ const server: Server = createServer((socket) => {
177
+ socket.setEncoding('utf-8');
178
+ let answered = false;
179
+ let received = 0;
180
+
181
+ const answer = (response: object) => {
182
+ if (answered) return;
183
+ answered = true;
184
+ socket.end(`${JSON.stringify(response)}\n`);
185
+ };
186
+
187
+ const feed = createLineReader((line) => {
188
+ if (answered) return;
189
+ options.onActivity();
190
+
191
+ let parsed: RemoteRequest;
192
+ try {
193
+ parsed = RemoteRequestSchema.parse(JSON.parse(line));
194
+ } catch (error) {
195
+ answer(
196
+ failure(
197
+ `remote-ops broker: malformed request (${error instanceof Error ? error.message : String(error)})`,
198
+ ),
199
+ );
200
+ return;
201
+ }
202
+
203
+ if (stopped) {
204
+ // Same rule as the capability broker: the kill is only real if the
205
+ // channel refuses afterwards (celilo#1003).
206
+ answer(refusal(parsed.op, 'Hook run has ended; refusing remote operation.'));
207
+ return;
208
+ }
209
+
210
+ answer(dispatch(parsed));
211
+ });
212
+
213
+ socket.on('data', (chunk: string) => {
214
+ received += chunk.length;
215
+ if (received > MAX_REQUEST_BYTES) {
216
+ answer(failure('remote-ops broker: request too large'));
217
+ socket.destroy();
218
+ return;
219
+ }
220
+ feed(chunk);
221
+ });
222
+ socket.on('error', () => {
223
+ // A client that died mid-request has nobody to answer.
224
+ });
225
+ });
226
+
227
+ function dispatch(request: RemoteRequest): object {
228
+ const hasOwnCredential =
229
+ request.op === 'installAuthorizedKey' || request.target.identityContent !== undefined;
230
+
231
+ const verdict: RemoteCheckResult = options.policy
232
+ ? options.policy.checkTarget(
233
+ { ipv4_address: request.target.ipv4_address, user: request.target.user },
234
+ hasOwnCredential,
235
+ )
236
+ : {
237
+ allowed: false,
238
+ message:
239
+ 'This hook invocation was started without a remote-access policy, so remote operations are unavailable. This is a celilo defect — the invoking service must pass one.',
240
+ };
241
+
242
+ if (!verdict.allowed) {
243
+ options.logger.warn(`remote-ops refused: ${verdict.message}`);
244
+ return refusal(request.op, verdict.message);
245
+ }
246
+
247
+ try {
248
+ return { ...perform(request) };
249
+ } catch (error) {
250
+ return failure(
251
+ `remote-ops broker: ${error instanceof Error ? error.message : String(error)}`,
252
+ );
253
+ }
254
+ }
255
+
256
+ function perform(request: RemoteRequest): RunResult | RemoteCheckResult {
257
+ switch (request.op) {
258
+ case 'checkTarget':
259
+ return { allowed: true };
260
+
261
+ case 'remoteExec':
262
+ return withIdentity(request.target, (target) =>
263
+ remoteExec(target, request.command, request.opts ?? {}, runner),
264
+ );
265
+
266
+ case 'streamBackup': {
267
+ const local = containedPath(request.localPath, writableRoots, 'write');
268
+ if ('error' in local) return { ok: false, stdout: '', stderr: local.error };
269
+ return withIdentity(request.target, (target) =>
270
+ streamBackup(target, request.producerCommand, local.path, runner, request.opts ?? {}),
271
+ );
272
+ }
273
+
274
+ case 'streamRestore': {
275
+ const local = containedPath(request.localPath, readableRoots, 'read');
276
+ if ('error' in local) return { ok: false, stdout: '', stderr: local.error };
277
+ return withIdentity(request.target, (target) =>
278
+ streamRestore(target, local.path, request.consumerCommand, runner, request.opts ?? {}),
279
+ );
280
+ }
281
+
282
+ case 'installAuthorizedKey':
283
+ return withIdentity(request.target, (target) =>
284
+ installAuthorizedKey(target, request.password, runner, request.opts ?? {}),
285
+ );
286
+ }
287
+ }
288
+
289
+ await new Promise<void>((resolvePromise, reject) => {
290
+ server.once('error', reject);
291
+ server.listen(socketPath, resolvePromise);
292
+ });
293
+
294
+ return {
295
+ socketPath,
296
+ stop: () => {
297
+ stopped = true;
298
+ },
299
+ close: () => {
300
+ stopped = true;
301
+ server.close();
302
+ },
303
+ };
304
+ }
305
+
306
+ /** A refusal in the shape the refused op's caller reads. */
307
+ function refusal(op: RemoteRequest['op'], message: string): object {
308
+ return op === 'checkTarget' ? { allowed: false, message } : failure(message);
309
+ }
310
+
311
+ function failure(message: string): { ok: false; stdout: ''; stderr: string } {
312
+ return { ok: false, stdout: '', stderr: message };
313
+ }
314
+
315
+ /**
316
+ * Materialise the module's own key for the one call (task 5.4), then remove
317
+ * it. Content in, path out: the asking half read the bytes inside the jail,
318
+ * and the file below exists only for ssh's `-i`, which takes no stdin.
319
+ */
320
+ function withIdentity(target: BridgeTarget, run: (target: RemoteTarget) => RunResult): RunResult {
321
+ const base: RemoteTarget = {
322
+ ipv4_address: target.ipv4_address,
323
+ ...(target.user !== undefined ? { user: target.user } : {}),
324
+ ...(target.port !== undefined ? { port: target.port } : {}),
325
+ };
326
+ if (target.identityContent === undefined) return run(base);
327
+
328
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-hook-identity-'));
329
+ const keyPath = join(dir, 'key');
330
+ try {
331
+ writeFileSync(keyPath, target.identityContent, { mode: 0o600 });
332
+ return run({ ...base, identityFile: keyPath });
333
+ } finally {
334
+ rmSync(dir, { recursive: true, force: true });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Confine a stream primitive's LOCAL path to this run's granted roots.
340
+ *
341
+ * Every component is resolved through the filesystem before the containment
342
+ * test. For a read, `realpath` of the whole path does that. For a write the
343
+ * leaf may not exist yet, so the PARENT is realpath'd — and a leaf that does
344
+ * exist as a symlink is refused outright: the shell's `>` follows it, so a
345
+ * link planted inside a writable directory (`state/evil` →
346
+ * `/var/celilo/master.key`, dangling inside the jail, resolving outside it)
347
+ * would otherwise aim celilo's write anywhere. Refusing the link beats
348
+ * resolving it, because the hook's own subprocesses could re-point it
349
+ * between a check and the write.
350
+ *
351
+ * The mount set binds the same path inside and outside the jail, which is
352
+ * what makes a contained path name the same file for both sides — and why a
353
+ * path OUTSIDE the roots (a bare `/tmp/x` under the run's private tmpfs)
354
+ * must be refused: celilo would write a file the hook then cannot even see.
355
+ */
356
+ function containedPath(
357
+ localPath: string,
358
+ roots: readonly string[],
359
+ access: 'read' | 'write',
360
+ ): { path: string } | { error: string } {
361
+ let real: string;
362
+ try {
363
+ if (access === 'read') {
364
+ real = realpathSync(resolve(localPath));
365
+ } else {
366
+ const resolved = resolve(localPath);
367
+ real = join(realpathSync(dirname(resolved)), basename(resolved));
368
+ // lstat, not existsSync: a dangling link (its target absent inside the
369
+ // jail) still IS a link, and that is the case that aims celilo's write
370
+ // out of the jail. existsSync follows the link and misses it.
371
+ if (isSymlink(real)) {
372
+ return {
373
+ error: `remote-ops broker: local path '${localPath}' is a symlink; a stream write follows links, so it must name the file itself.`,
374
+ };
375
+ }
376
+ }
377
+ } catch (error) {
378
+ return {
379
+ error: `remote-ops broker: local path '${localPath}' is not usable (${error instanceof Error ? error.message : String(error)})`,
380
+ };
381
+ }
382
+
383
+ if (roots.some((root) => real === root || real.startsWith(root + sep))) return { path: real };
384
+ return {
385
+ error: `remote-ops broker: local path '${localPath}' is outside this run's ${access === 'write' ? 'writable' : 'readable'} directories. Use ctx.stateDir or a declared path input (backup_dir, restore_dir).`,
386
+ };
387
+ }
388
+
389
+ function realpathOrSelf(path: string): string {
390
+ try {
391
+ return realpathSync(resolve(path));
392
+ } catch {
393
+ return resolve(path);
394
+ }
395
+ }
396
+
397
+ /** True if `path` itself is a symlink, dangling target included. */
398
+ function isSymlink(path: string): boolean {
399
+ try {
400
+ return lstatSync(path).isSymbolicLink();
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
@@ -23,6 +23,7 @@ import {
23
23
  listDnsRegistrations,
24
24
  stampDnsRegistrationsRefreshed,
25
25
  } from '../services/dns-registrations';
26
+ import { remoteAccessPolicy } from '../services/remote-access';
26
27
  import { loadCapabilityFunctions } from './capability-loader';
27
28
  import { invokeHook } from './executor';
28
29
  import { loadHookConfigMap } from './load-hook-config';
@@ -190,6 +191,7 @@ export async function runNamedHook(
190
191
  capabilities: capabilityFunctions,
191
192
  requiredCapabilities,
192
193
  systems: getModuleSystems(moduleId, db),
194
+ remoteAccess: remoteAccessPolicy(moduleId, db),
193
195
  timeoutMs: options.timeoutMs,
194
196
  },
195
197
  );
@@ -8,7 +8,9 @@
8
8
  * whether the access worked and hands the message back for the post-mortem.
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync } from 'node:fs';
11
+ import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
12
14
  import { defineHook } from '@celilo/capabilities';
13
15
 
14
16
  function probe(fn: () => void): { succeeded: boolean; detail: string } {
@@ -54,6 +56,17 @@ export default defineHook({
54
56
  staged_write: probe(() => {
55
57
  writeFileSync(`${config.staged_input}/produced`, 'staged input survived the tmpfs');
56
58
  }),
59
+ // Stage 3's observable (design D12, task 5.5): the SSH credential is
60
+ // not bound, so a hook cannot authenticate anywhere by hand. Reads
61
+ // `homedir()/.ssh` — the exact path the pre-stage-3 jail bound (via
62
+ // `join(homedir(), '.ssh')`), so the red baseline reaches a planted key
63
+ // and this branch does not. `homedir()` rather than `process.env.HOME`
64
+ // because that is what the mount decision used, and the two can differ.
65
+ ssh_key: probe(() => {
66
+ const sshDir = join(homedir(), '.ssh');
67
+ const names = readdirSync(sshDir);
68
+ for (const name of names) readFileSync(join(sshDir, name));
69
+ }),
57
70
  };
58
71
  },
59
72
  });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Test fixture: what TOOLING can a hook reach from inside the jail (task 4.13)?
3
+ *
4
+ * `jail-probe-hook.ts` next door asks what a hook can reach on the FILESYSTEM,
5
+ * and D9's table is written in those terms. This one asks the question D9's
6
+ * table does not answer: the fleet's real hooks shell out. `remote.ts` builds an
7
+ * `ssh <user>@<host> <cmd>` string and hands it to `execSync`, which spawns
8
+ * `/bin/sh -c`. Neither `/bin/sh` nor `/usr/bin/ssh` appears anywhere in the
9
+ * derivation, and 22 module script files import `node:child_process`.
10
+ *
11
+ * So every probe here reports an outcome rather than throwing, the same way its
12
+ * neighbour does. The point is to MEASURE the reach rather than reason about it:
13
+ * reading the derivation is what produced the belief that stage 2 was fine, and
14
+ * reading it again would produce the same belief.
15
+ *
16
+ * `bwrap` is probed too, and its expected answer is the opposite of every other
17
+ * one here. It must NOT be reachable. D9 predicts exactly how that gets undone —
18
+ * a hook fails on a missing binary, somebody binds the directory it lives in,
19
+ * and the escape path reopens with a green suite — so the guard belongs in the
20
+ * same run as the failures that would tempt someone into it.
21
+ */
22
+
23
+ import { execFileSync, execSync } from 'node:child_process';
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { dirname } from 'node:path';
26
+ import { BROWSER_ROOT, defineHook } from '@celilo/capabilities';
27
+
28
+ interface Probe {
29
+ succeeded: boolean;
30
+ detail: string;
31
+ }
32
+
33
+ function probe(fn: () => string): Probe {
34
+ try {
35
+ return { succeeded: true, detail: fn().slice(0, 200) };
36
+ } catch (error) {
37
+ return { succeeded: false, detail: error instanceof Error ? error.message : String(error) };
38
+ }
39
+ }
40
+
41
+ async function probeAsync(fn: () => Promise<string>): Promise<Probe> {
42
+ try {
43
+ return { succeeded: true, detail: (await fn()).slice(0, 200) };
44
+ } catch (error) {
45
+ return { succeeded: false, detail: error instanceof Error ? error.message : String(error) };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Launch args for the two arms of task 4.10's controlled experiment, exported
51
+ * so the unit test that pins the flag on the launch path asserts the SAME
52
+ * definition the probes launch with rather than a copy of it.
53
+ *
54
+ * `SANDBOXED_LAUNCH_ARGS` is the experiment arm: Chromium's own sandbox on,
55
+ * which does not start inside the jail (its namespace sandbox cannot nest in
56
+ * the unprivileged user namespace bwrap creates). It must stay sandboxed or
57
+ * the comparison stops being one that isolates a variable.
58
+ *
59
+ * `JAILED_LAUNCH_ARGS` is the launch path itself, and the interim celilo#1215
60
+ * approved: a Chromium launched by a jailed hook starts with `--no-sandbox`,
61
+ * because the jail is then the only sandbox around the browser. The flag
62
+ * belongs here, in the launcher — never in the mount set or any other
63
+ * contract-shaped place. Brokered rendering outside the jail (v2, celilo#1215)
64
+ * is the preferred end state and removes the need for this flag.
65
+ */
66
+ export const SANDBOXED_LAUNCH_ARGS: readonly string[] = ['--headless', '--dump-dom', 'about:blank'];
67
+ export const JAILED_LAUNCH_ARGS: readonly string[] = [
68
+ '--headless',
69
+ '--no-sandbox',
70
+ '--dump-dom',
71
+ 'about:blank',
72
+ ];
73
+
74
+ export default defineHook({
75
+ hook: 'container_created',
76
+ requires: [],
77
+ handler: async (ctx) => {
78
+ // Through `ctx.config`, not an env var. Stage 1 stopped forwarding the
79
+ // process environment into hooks (celilo#1158), so a probe keyed on
80
+ // `process.env` would read empty and report "no browser supplied" for a
81
+ // run that supplied one — a false ABSENT indistinguishable from a real one.
82
+ const browser = (ctx.config as { browser_executable?: string }).browser_executable ?? '';
83
+
84
+ return {
85
+ // The one every other shell-out depends on. `execSync` spawns
86
+ // `/bin/sh -c`, so a jail without it cannot run ANY hook that uses
87
+ // `node:child_process`, whatever binary that hook was reaching for.
88
+ shell: probe(() => execSync('echo alive', { encoding: 'utf-8' })),
89
+
90
+ // Bypasses the shell to separate two failures that look identical
91
+ // through `execSync`: no `/bin/sh`, versus no target binary.
92
+ exec_without_shell: probe(() => execFileSync('/bin/echo', ['alive'], { encoding: 'utf-8' })),
93
+
94
+ // What `remote.ts` needs. Stage 2 binds `~/.ssh` read-only ON PURPOSE so
95
+ // that remote.ts keeps working, which makes the absence of the binary it
96
+ // feeds that key to the interesting result rather than a detail.
97
+ ssh: probe(() => execFileSync('/usr/bin/ssh', ['-V'], { encoding: 'utf-8', stdio: 'pipe' })),
98
+
99
+ // The deploy path. `ansible-playbook` is a Python program, so a bind of
100
+ // the binary alone would not be enough even if one existed.
101
+ ansible: probe(() =>
102
+ execFileSync('/usr/bin/ansible-playbook', ['--version'], { encoding: 'utf-8' }),
103
+ ),
104
+
105
+ // Name resolution, in two halves, because they fail for different
106
+ // reasons and only one of them is about the mount set.
107
+ resolv_conf: probe(() => readFileSync('/etc/resolv.conf', 'utf-8')),
108
+ dns_lookup: await probeAsync(async () => {
109
+ // Bun's own resolver, so this measures name resolution rather than the
110
+ // absence of some binary that happens to do it. A jail with no
111
+ // `/etc/resolv.conf` has no nameserver to ask.
112
+ // Bounded, because the interesting failure is a HANG. With no
113
+ // `/etc/resolv.conf` there is no nameserver to ask and the resolver
114
+ // waits out its own retry schedule, so an unbounded probe reports the
115
+ // test runner's timeout instead of the jail's behaviour.
116
+ const answer = await Promise.race([
117
+ Bun.dns.lookup('example.com', { family: 4 }),
118
+ new Promise<never>((_, reject) =>
119
+ setTimeout(() => reject(new Error('no answer within 3s')), 3_000),
120
+ ),
121
+ ]);
122
+ return answer[0]?.address ?? 'resolved, no address';
123
+ }),
124
+
125
+ // Files a resolver reads before it ever touches the network.
126
+ nsswitch: probe(() => readFileSync('/etc/nsswitch.conf', 'utf-8')),
127
+ hosts_file: probe(() => readFileSync('/etc/hosts', 'utf-8')),
128
+
129
+ // ── Task 4.10, the browser case ──────────────────────────────────
130
+ //
131
+ // The task says to bind `~/.cache/ms-playwright`. That path is stale:
132
+ // `managed-browser-runtime` has since landed `BROWSER_ROOT` in
133
+ // `@celilo/capabilities`, so a provisioned browser lives under it and
134
+ // `resolveBrowser()` is what a hook asks for one. Both are probed, and
135
+ // only the playwright cache is ASSERTED — see the suite for why.
136
+ playwright_cache: probe(() => {
137
+ const home = process.env.HOME ?? '/root';
138
+ if (existsSync(`${home}/.cache/ms-playwright`)) return 'present';
139
+ throw new Error(`no ${home}/.cache/ms-playwright`);
140
+ }),
141
+ celilo_browser_root: probe(() => {
142
+ if (existsSync(BROWSER_ROOT)) return `present at ${BROWSER_ROOT}`;
143
+ throw new Error(`no ${BROWSER_ROOT} on this host`);
144
+ }),
145
+
146
+ // ⚠️ BROWSER_ROOT is a SUBDIRECTORY of celilo's Linux data directory
147
+ // (`/var/lib/celilo`), which also holds `celilo.db` and `master.key`.
148
+ // Binding it must not expose its siblings. bubblewrap creates the parent
149
+ // as an empty directory in the jail's namespace, so `..` reaches
150
+ // nothing — but that is the kind of claim worth a probe rather than a
151
+ // sentence, because it is the whole acceptance criterion D9 satisfies
152
+ // by absence.
153
+ data_dir_sibling: probe(() => {
154
+ const parent = dirname(BROWSER_ROOT);
155
+ return readFileSync(`${parent}/master.key`, 'utf-8');
156
+ }),
157
+
158
+ // Why a present binary can still fail to exec. `posix_spawn` reports a
159
+ // missing ELF interpreter as ENOENT on the PROGRAM, which is
160
+ // indistinguishable from the program being absent. Probed rather than
161
+ // inferred, and worth it: the loader turned out to be present and the
162
+ // first hypothesis was wrong.
163
+ dynamic_loader: probe(() => {
164
+ const found: string[] = [];
165
+ for (const dir of ['/lib', '/lib64', '/usr/lib']) {
166
+ try {
167
+ for (const name of readdirSync(dir)) {
168
+ if (name.startsWith('ld-linux') || name.startsWith('ld.so')) {
169
+ found.push(`${dir}/${name}`);
170
+ }
171
+ }
172
+ } catch {
173
+ // A directory the derivation legitimately skipped: `/lib64` does
174
+ // not exist on arm64 and `planJailedSpawn` drops a missing source.
175
+ }
176
+ }
177
+ if (found.length === 0) {
178
+ throw new Error('no ld-linux loader under /lib, /lib64 or /usr/lib');
179
+ }
180
+ return found.join(', ');
181
+ }),
182
+
183
+ // Did the bind happen at all? Separates "no browser reached the jail"
184
+ // from "one did and will not run", which the two launches below cannot
185
+ // tell apart on their own.
186
+ browser_binary_present: probe(() => {
187
+ if (!browser) throw new Error('no browser supplied to this run');
188
+ if (existsSync(browser)) return `present at ${browser}`;
189
+ throw new Error(`absent at ${browser}`);
190
+ }),
191
+
192
+ // The nesting question, and the only pair here needing a real browser.
193
+ //
194
+ // Two launches differing in ONE flag, so the result is a controlled
195
+ // experiment rather than an observation. Chromium's own sandbox forks a
196
+ // helper into a NEW user namespace, and nesting that inside
197
+ // bubblewrap's unprivileged one is what D9 and task 4.10 both predict
198
+ // will fail. `--no-sandbox` skips the fork, so the launch that must
199
+ // succeed carries it (celilo#1215 interim). The args come from the
200
+ // exported constants above, which the flag-pinning unit test shares.
201
+ browser_sandboxed: probe(() => {
202
+ if (!browser) throw new Error('no browser supplied to this run');
203
+ return execFileSync(browser, [...SANDBOXED_LAUNCH_ARGS], {
204
+ encoding: 'utf-8',
205
+ stdio: 'pipe',
206
+ timeout: 20_000,
207
+ });
208
+ }),
209
+ browser_no_sandbox: probe(() => {
210
+ if (!browser) throw new Error('no browser supplied to this run');
211
+ return execFileSync(browser, [...JAILED_LAUNCH_ARGS], {
212
+ encoding: 'utf-8',
213
+ stdio: 'pipe',
214
+ timeout: 20_000,
215
+ });
216
+ }),
217
+
218
+ // MUST be false. See the docblock.
219
+ bwrap_present: probe(() => {
220
+ for (const path of ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap']) {
221
+ if (existsSync(path)) return `present at ${path}`;
222
+ }
223
+ throw new Error('no bwrap on any known path');
224
+ }),
225
+ };
226
+ },
227
+ });