@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +26 -3
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-publish.test.ts +18 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +1 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader.ts +15 -2
- package/src/hooks/executor.ts +116 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
- package/src/hooks/hook-jail-unreachability.test.ts +77 -26
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +74 -12
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +304 -32
- package/src/hooks/mount-set.test.ts +116 -7
- package/src/hooks/mount-set.ts +189 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +254 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/policy/module-business-baseline.ts +13 -1
- package/src/policy/module-script-scan.ts +60 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/backup-create.ts +3 -0
- package/src/services/backup-restore.ts +2 -0
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +71 -0
- package/src/services/remote-access.test.ts +223 -0
- package/src/services/remote-access.ts +149 -0
- package/src/services/restore-from-file.ts +6 -1
- package/src/services/static-content-converge.test.ts +338 -0
- 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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
+
});
|
package/src/hooks/jail.test.ts
CHANGED
|
@@ -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
|
});
|