@celilo/cli 1.14.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 (57) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +25 -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 +14 -0
  17. package/src/hooks/executor.ts +110 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  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 +9 -3
  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 +128 -11
  27. package/src/hooks/mount-set.test.ts +28 -6
  28. package/src/hooks/mount-set.ts +34 -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 +251 -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/hook-jail.test.ts +66 -0
  42. package/src/services/alerting/hook-jail.ts +70 -0
  43. package/src/services/alerting/run-monitor.test.ts +62 -0
  44. package/src/services/alerting/run-monitor.ts +12 -0
  45. package/src/services/alerting/sweep-runner.test.ts +1 -0
  46. package/src/services/backup-create.ts +3 -0
  47. package/src/services/backup-restore.ts +2 -0
  48. package/src/services/deploy-ansible.ts +9 -1
  49. package/src/services/health-runner.ts +2 -0
  50. package/src/services/module-build.test.ts +1 -64
  51. package/src/services/module-build.ts +10 -86
  52. package/src/services/module-deploy.ts +20 -0
  53. package/src/services/remote-access.test.ts +139 -0
  54. package/src/services/remote-access.ts +98 -0
  55. package/src/services/restore-from-file.ts +6 -1
  56. package/src/services/static-content-converge.test.ts +338 -0
  57. package/src/services/static-content-converge.ts +299 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * The remote-ops broker, tested through the REAL asking half (tasks 5.1/5.2,
3
+ * design D12).
4
+ *
5
+ * Every bridged case below runs the actual `@celilo/capabilities` primitive
6
+ * with `CELILO_HOOK_REMOTE_SOCKET` set — in a SEPARATE process
7
+ * (`test-fixtures/remote-bridge-probe.ts`), because that is the only topology
8
+ * that exists: the bridge blocks its caller in spawnSync while celilo's event
9
+ * loop answers the socket, so asking and answering can never share a process.
10
+ * The round trip therefore exercises what a hook exercises: the primitive's
11
+ * bridge detection, the spawned client, the socket, the Zod parse, the
12
+ * policy, and the broker-side primitive — with the one seam a unit test must
13
+ * inject, the runner that would otherwise run ssh.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { connect } from 'node:net';
19
+ import { tmpdir } from 'node:os';
20
+ import { join, resolve } from 'node:path';
21
+ import { type MockRunner, type RunResult, createMockRunner } from '@celilo/capabilities';
22
+ import { createCapturingLogger } from './logger';
23
+ import { type RemoteAccessPolicy, type RemoteBroker, startRemoteBroker } from './remote-broker';
24
+
25
+ const REMOTE_SOCKET_ENV = 'CELILO_HOOK_REMOTE_SOCKET';
26
+ const PROBE = resolve(__dirname, 'test-fixtures/remote-bridge-probe.ts');
27
+
28
+ const ALLOW_ALL: RemoteAccessPolicy = {
29
+ moduleId: 'caddy',
30
+ checkTarget: () => ({ allowed: true }),
31
+ };
32
+
33
+ const DENY_ALL: RemoteAccessPolicy = {
34
+ moduleId: 'caddy',
35
+ checkTarget: () => ({ allowed: false, message: 'policy says no' }),
36
+ };
37
+
38
+ /**
39
+ * The shape of the real policy's first rule (`services/remote-access.ts`):
40
+ * a request on the module's own credential is allowed, everything else is
41
+ * refused. Used to prove the broker computes and forwards
42
+ * `hasOwnCredential` — the decision itself is the policy's.
43
+ */
44
+ const OWN_CREDENTIAL_ONLY: RemoteAccessPolicy = {
45
+ moduleId: 'generic-cpanel-hosting-provider',
46
+ checkTarget: (_target, hasOwnCredential) =>
47
+ hasOwnCredential ? { allowed: true } : { allowed: false, message: 'fleet key refused' },
48
+ };
49
+
50
+ describe('remote-ops broker', () => {
51
+ let scratch: string;
52
+ let socketDir: string;
53
+ let stateDir: string;
54
+ let broker: RemoteBroker | undefined;
55
+ let runner: MockRunner;
56
+
57
+ beforeEach(() => {
58
+ scratch = mkdtempSync(join(tmpdir(), 'celilo-remote-broker-'));
59
+ // Short-named like the real run directory: sun_path is 104 bytes on macOS.
60
+ socketDir = mkdtempSync(join(tmpdir(), 'celilo-hook-'));
61
+ stateDir = join(scratch, 'state');
62
+ mkdirSync(stateDir, { recursive: true });
63
+ runner = createMockRunner([
64
+ { match: 'ssh', result: { ok: true, stdout: 'remote-ok', stderr: '' } },
65
+ ]);
66
+ });
67
+
68
+ afterEach(() => {
69
+ broker?.close();
70
+ broker = undefined;
71
+ rmSync(scratch, { recursive: true, force: true });
72
+ rmSync(socketDir, { recursive: true, force: true });
73
+ });
74
+
75
+ async function start(policy: RemoteAccessPolicy | undefined): Promise<void> {
76
+ const { logger } = createCapturingLogger();
77
+ broker = await startRemoteBroker({
78
+ socketDir,
79
+ policy,
80
+ readableRoots: [scratch],
81
+ writableRoots: [stateDir],
82
+ logger,
83
+ onActivity: () => {},
84
+ runner: runner.run,
85
+ });
86
+ }
87
+
88
+ /** Run one primitive from a hook-shaped process; return what it returned. */
89
+ async function fromHookProcess<T = RunResult>(instruction: Record<string, unknown>): Promise<T> {
90
+ const proc = Bun.spawn({
91
+ cmd: [process.execPath, PROBE, JSON.stringify(instruction)],
92
+ env: { ...process.env, [REMOTE_SOCKET_ENV]: (broker as RemoteBroker).socketPath },
93
+ stdout: 'pipe',
94
+ stderr: 'pipe',
95
+ });
96
+ const [stdout, stderr] = await Promise.all([
97
+ new Response(proc.stdout).text(),
98
+ new Response(proc.stderr).text(),
99
+ ]);
100
+ const code = await proc.exited;
101
+ if (code !== 0) throw new Error(`probe exited ${code}: ${stderr}`);
102
+ return JSON.parse(stdout) as T;
103
+ }
104
+
105
+ test('bridges remoteExec end to end, and the broker builds the ssh line itself', async () => {
106
+ await start(ALLOW_ALL);
107
+
108
+ const result = await fromHookProcess({
109
+ op: 'remoteExec',
110
+ target: { ipv4_address: '10.0.10.10' },
111
+ command: 'echo hi',
112
+ });
113
+
114
+ expect(result).toEqual({ ok: true, stdout: 'remote-ok', stderr: '' });
115
+ expect(runner.calls).toHaveLength(1);
116
+ expect(runner.calls[0]?.cmd).toContain('root@10.0.10.10');
117
+ expect(runner.calls[0]?.cmd).toContain("'echo hi'");
118
+ });
119
+
120
+ test('sugar primitives ride the bridge with no awareness of their own', async () => {
121
+ await start(ALLOW_ALL);
122
+
123
+ const result = await fromHookProcess({
124
+ op: 'serviceCtl',
125
+ target: { ipv4_address: '10.0.10.10' },
126
+ unit: 'caddy',
127
+ action: 'restart',
128
+ });
129
+
130
+ expect(result.ok).toBe(true);
131
+ expect(runner.calls[0]?.cmd).toContain('systemctl restart caddy');
132
+ });
133
+
134
+ test('a policy refusal comes back as the failed RunResult, and nothing runs', async () => {
135
+ await start(DENY_ALL);
136
+
137
+ const result = await fromHookProcess({
138
+ op: 'remoteExec',
139
+ target: { ipv4_address: '10.0.30.7' },
140
+ command: 'id -un',
141
+ });
142
+
143
+ expect(result.ok).toBe(false);
144
+ expect(result.stderr).toContain('policy says no');
145
+ expect(runner.calls).toHaveLength(0);
146
+ });
147
+
148
+ test('no policy means deny, naming the gap (Rule 6.4)', async () => {
149
+ await start(undefined);
150
+
151
+ const result = await fromHookProcess({
152
+ op: 'remoteExec',
153
+ target: { ipv4_address: '10.0.10.10' },
154
+ command: 'echo hi',
155
+ });
156
+
157
+ expect(result.ok).toBe(false);
158
+ expect(result.stderr).toContain('without a remote-access policy');
159
+ expect(runner.calls).toHaveLength(0);
160
+ });
161
+
162
+ test('streamBackup writes only inside the writable roots', async () => {
163
+ await start(ALLOW_ALL);
164
+
165
+ const inside = await fromHookProcess({
166
+ op: 'streamBackup',
167
+ target: { ipv4_address: '10.0.10.10' },
168
+ producerCommand: 'cat /etc/x',
169
+ localPath: join(stateDir, 'x'),
170
+ });
171
+ expect(inside.ok).toBe(true);
172
+ // The broker realpaths before it binds the containment decision to the
173
+ // command, so assert on the resolved path (macOS: /var → /private/var).
174
+ expect(runner.calls[0]?.cmd).toContain(join(realpathSync(stateDir), 'x'));
175
+
176
+ const outside = await fromHookProcess({
177
+ op: 'streamBackup',
178
+ target: { ipv4_address: '10.0.10.10' },
179
+ producerCommand: 'cat /etc/x',
180
+ localPath: join(scratch, 'not-writable'),
181
+ });
182
+ expect(outside.ok).toBe(false);
183
+ expect(outside.stderr).toContain('writable directories');
184
+ expect(runner.calls).toHaveLength(1);
185
+ });
186
+
187
+ test('streamBackup refuses a symlink leaf inside a writable root', async () => {
188
+ // The shell's `>` follows a link, so `state/evil` → anywhere would aim
189
+ // celilo's write outside the roots. Inside the jail the link can be
190
+ // dangling (the target path does not exist THERE) and still resolve on
191
+ // the broker's side — which is exactly why the leaf is refused rather
192
+ // than resolved.
193
+ await start(ALLOW_ALL);
194
+ const { symlinkSync } = await import('node:fs');
195
+ symlinkSync('/var/celilo/master.key', join(stateDir, 'evil'));
196
+
197
+ const result = await fromHookProcess({
198
+ op: 'streamBackup',
199
+ target: { ipv4_address: '10.0.10.10' },
200
+ producerCommand: 'cat /etc/x',
201
+ localPath: join(stateDir, 'evil'),
202
+ });
203
+
204
+ expect(result.ok).toBe(false);
205
+ expect(result.stderr).toContain('symlink');
206
+ expect(runner.calls).toHaveLength(0);
207
+ });
208
+
209
+ test('streamRestore reads only inside the readable roots', async () => {
210
+ await start(ALLOW_ALL);
211
+ const source = join(scratch, 'payload.tar');
212
+ writeFileSync(source, 'bytes');
213
+
214
+ const inside = await fromHookProcess({
215
+ op: 'streamRestore',
216
+ target: { ipv4_address: '10.0.10.10' },
217
+ localPath: source,
218
+ consumerCommand: 'tar -xf -',
219
+ });
220
+ expect(inside.ok).toBe(true);
221
+
222
+ const outside = await fromHookProcess({
223
+ op: 'streamRestore',
224
+ target: { ipv4_address: '10.0.10.10' },
225
+ localPath: '/etc/passwd',
226
+ consumerCommand: 'cat',
227
+ });
228
+ expect(outside.ok).toBe(false);
229
+ expect(outside.stderr).toContain('readable directories');
230
+ });
231
+
232
+ test('an identityFile crosses as content, is materialised for the call, and is removed', async () => {
233
+ // A target carrying its own credential reaches the policy with
234
+ // hasOwnCredential=true, which the real policy's first rule allows.
235
+ await start(OWN_CREDENTIAL_ONLY);
236
+ const keyPath = join(scratch, 'own-key');
237
+ writeFileSync(keyPath, 'not-a-real-key\n');
238
+
239
+ const result = await fromHookProcess({
240
+ op: 'remoteExec',
241
+ target: { ipv4_address: '198.51.100.7', user: 'peba-hosting', identityFile: keyPath },
242
+ command: 'echo hi',
243
+ });
244
+
245
+ expect(result.ok).toBe(true);
246
+ const cmd = runner.calls[0]?.cmd ?? '';
247
+ const materialized = cmd.match(/-i (\S*celilo-hook-identity-[^ ]*)/)?.[1];
248
+ expect(materialized).toBeDefined();
249
+ expect(materialized).not.toBe(keyPath);
250
+ expect(existsSync(materialized as string)).toBe(false);
251
+ });
252
+
253
+ test("installAuthorizedKey is brokered and its password counts as the module's own credential", async () => {
254
+ runner = createMockRunner([
255
+ { match: 'ssh-copy-id', result: { ok: true, stdout: 'installed', stderr: '' } },
256
+ ]);
257
+ // The password IS the credential: the broker reports hasOwnCredential
258
+ // and the policy's own-credential rule is what allows the call.
259
+ await start(OWN_CREDENTIAL_ONLY);
260
+
261
+ const result = await fromHookProcess({
262
+ op: 'installAuthorizedKey',
263
+ target: { ipv4_address: '198.51.100.7', user: 'peba-hosting', port: 7822 },
264
+ password: 'hunter2',
265
+ });
266
+
267
+ expect(result.ok).toBe(true);
268
+ expect(runner.calls[0]?.cmd).toContain('ssh-copy-id');
269
+ expect(runner.calls[0]?.cmd).toContain('peba-hosting@198.51.100.7');
270
+ // The password rides the child environment, never the command line.
271
+ expect(runner.calls[0]?.cmd).not.toContain('hunter2');
272
+ });
273
+
274
+ test('probeHttp asks the broker about the target and honours a refusal', async () => {
275
+ await start(DENY_ALL);
276
+
277
+ const refused = await fromHookProcess<{ healthy: boolean; failure?: string; detail: string }>({
278
+ op: 'probeHttpRefused',
279
+ target: { ipv4_address: '10.0.10.10' },
280
+ });
281
+
282
+ expect(refused.healthy).toBe(false);
283
+ expect(refused.failure).toBe('refused');
284
+ expect(refused.detail).toContain('policy says no');
285
+ });
286
+
287
+ test('probeHttp fetches when the broker allows the target', async () => {
288
+ await start(ALLOW_ALL);
289
+
290
+ const result = await fromHookProcess<{ healthy: boolean; detail: string }>({
291
+ op: 'probeHttpAllowed',
292
+ target: { ipv4_address: '10.0.10.10' },
293
+ });
294
+
295
+ expect(result).toEqual({ healthy: true, detail: 'GET http://10.0.10.10:80/ → 200' });
296
+ });
297
+
298
+ test('opts.env is refused on the asking side before anything crosses', async () => {
299
+ await start(ALLOW_ALL);
300
+
301
+ const result = await fromHookProcess({
302
+ op: 'remoteExec',
303
+ target: { ipv4_address: '10.0.10.10' },
304
+ command: 'echo hi',
305
+ opts: { env: { LD_PRELOAD: '/tmp/evil.so' } },
306
+ });
307
+
308
+ expect(result.ok).toBe(false);
309
+ expect(result.stderr).toContain('opts.env does not cross');
310
+ expect(runner.calls).toHaveLength(0);
311
+ });
312
+
313
+ test('stop() refuses further operations, which is what makes a kill real', async () => {
314
+ await start(ALLOW_ALL);
315
+ broker?.stop();
316
+
317
+ const result = await fromHookProcess({
318
+ op: 'remoteExec',
319
+ target: { ipv4_address: '10.0.10.10' },
320
+ command: 'echo hi',
321
+ });
322
+
323
+ expect(result.ok).toBe(false);
324
+ expect(result.stderr).toContain('Hook run has ended');
325
+ expect(runner.calls).toHaveLength(0);
326
+ });
327
+
328
+ test('a malformed request line is answered, not hung on', async () => {
329
+ await start(ALLOW_ALL);
330
+
331
+ const answer = await new Promise<string>((resolvePromise, reject) => {
332
+ const socket = connect((broker as RemoteBroker).socketPath);
333
+ socket.setEncoding('utf-8');
334
+ let buffer = '';
335
+ socket.on('connect', () => socket.write('this is not JSON\n'));
336
+ socket.on('data', (chunk: string) => {
337
+ buffer += chunk;
338
+ if (buffer.includes('\n')) {
339
+ socket.end();
340
+ resolvePromise(buffer);
341
+ }
342
+ });
343
+ socket.on('error', reject);
344
+ });
345
+
346
+ const parsed = JSON.parse(answer) as { ok: boolean; stderr: string };
347
+ expect(parsed.ok).toBe(false);
348
+ expect(parsed.stderr).toContain('malformed request');
349
+ });
350
+ });