@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +31 -5
- 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-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- 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 +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -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 +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -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/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/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `control_plane_api` reaches a consuming module, and only a consuming module.
|
|
3
|
+
*
|
|
4
|
+
* The rest of `loadCapabilityFunctions` hands over every capability it can
|
|
5
|
+
* build, declared or not — its own comment says "not just required ones", and
|
|
6
|
+
* `capability-loader-bindings.test.ts` asserts that shape deliberately. That is
|
|
7
|
+
* fine for a capability whose worst outcome is an unused OIDC client.
|
|
8
|
+
*
|
|
9
|
+
* This one mints an SSH principal into celilo's own control plane, so it is
|
|
10
|
+
* gated on the `requires` line instead. These pin both halves, because the
|
|
11
|
+
* failure that matters is silent in both directions: a console that declared it
|
|
12
|
+
* and got nothing fails at install with "no provider is loaded" for a capability
|
|
13
|
+
* no module ever provides, and a module that declared nothing and got it anyway
|
|
14
|
+
* holds control-plane read that no manifest review would have shown.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import type { HookLogger } from '@celilo/capabilities';
|
|
19
|
+
import type { DbClient } from '../db/client';
|
|
20
|
+
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
21
|
+
import { loadCapabilityFunctions } from './capability-loader';
|
|
22
|
+
|
|
23
|
+
const noopLogger: HookLogger = {
|
|
24
|
+
info() {},
|
|
25
|
+
warn() {},
|
|
26
|
+
error() {},
|
|
27
|
+
success() {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function installModule(db: DbClient, moduleId: string, manifest: Record<string, unknown>): void {
|
|
31
|
+
db.$client.run(
|
|
32
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, '1.0.0', '/tmp/${moduleId}', ?)`,
|
|
33
|
+
[moduleId, moduleId, JSON.stringify(manifest)],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DECLARES = {
|
|
38
|
+
requires: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('control_plane_api injection', () => {
|
|
42
|
+
let db: DbClient;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
db = await setupTestDatabase();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await cleanupTestDatabase(db);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a module that requires it gets it, with no provider module deployed', async () => {
|
|
53
|
+
// The point of the whole decision: nothing provides this capability, and it
|
|
54
|
+
// is still there. A test that first deployed a provider would be measuring
|
|
55
|
+
// the ordinary path.
|
|
56
|
+
installModule(db, 'celilo-web-console', DECLARES);
|
|
57
|
+
|
|
58
|
+
const capabilities = await loadCapabilityFunctions('celilo-web-console', db, noopLogger);
|
|
59
|
+
|
|
60
|
+
expect(capabilities.control_plane_api).toBeTruthy();
|
|
61
|
+
const api = capabilities.control_plane_api as Record<string, unknown>;
|
|
62
|
+
expect(typeof api.enrol_principal).toBe('function');
|
|
63
|
+
expect(typeof api.revoke_principal).toBe('function');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a module that declares it as optional gets it too', async () => {
|
|
67
|
+
// `optional` is a declaration a reviewer reads on the same line of the same
|
|
68
|
+
// file. Treating it as no declaration would fail the module at runtime for a
|
|
69
|
+
// capability its manifest names.
|
|
70
|
+
installModule(db, 'someday-console', {
|
|
71
|
+
optional: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const capabilities = await loadCapabilityFunctions('someday-console', db, noopLogger);
|
|
75
|
+
|
|
76
|
+
expect(capabilities.control_plane_api).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a module that declares nothing does NOT get it', async () => {
|
|
80
|
+
installModule(db, 'hello-foo', { requires: { capabilities: [] } });
|
|
81
|
+
|
|
82
|
+
const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
|
|
83
|
+
|
|
84
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a module that requires something ELSE does NOT get it', async () => {
|
|
88
|
+
// The near miss: a manifest with a populated `requires` block is the shape a
|
|
89
|
+
// too-loose check ("does this module require anything?") would wave through.
|
|
90
|
+
installModule(db, 'hello-bar', {
|
|
91
|
+
requires: { capabilities: [{ name: 'idp', version: '1.0.0' }] },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const capabilities = await loadCapabilityFunctions('hello-bar', db, noopLogger);
|
|
95
|
+
|
|
96
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a module celilo has no record of does NOT get it', async () => {
|
|
100
|
+
// A hook can run for a module id with no row (a removal path, a typo). The
|
|
101
|
+
// manifest lookup returns nothing, and "no manifest" must read as "declared
|
|
102
|
+
// nothing" rather than throwing or defaulting open.
|
|
103
|
+
const capabilities = await loadCapabilityFunctions('never-imported', db, noopLogger);
|
|
104
|
+
|
|
105
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the injected object is scoped to the module it was built for', async () => {
|
|
109
|
+
// Scoping is what stops a module rotating a neighbour's key. It is asserted
|
|
110
|
+
// through the loader rather than only on `buildControlPlaneApi`, because the
|
|
111
|
+
// loader is where the wrong argument (a provider id, a hook name) would be
|
|
112
|
+
// passed.
|
|
113
|
+
installModule(db, 'hello-foo', DECLARES);
|
|
114
|
+
|
|
115
|
+
const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
|
|
116
|
+
const api = capabilities.control_plane_api as {
|
|
117
|
+
revoke_principal(request: { name: string }): Promise<unknown>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
|
|
121
|
+
/only revoke its own principal/,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
import {
|
|
16
16
|
createPublicWeb,
|
|
17
17
|
isCompiledCapabilityFactory,
|
|
18
|
+
orderFirewallChain,
|
|
18
19
|
wrapWithLogging,
|
|
19
20
|
} from '@celilo/capabilities';
|
|
20
21
|
import type {
|
|
@@ -38,8 +39,10 @@ import {
|
|
|
38
39
|
systemConfig,
|
|
39
40
|
webRoutes,
|
|
40
41
|
} from '../db/schema';
|
|
42
|
+
import { resolveModuleWebRoot } from '../module/web-root';
|
|
41
43
|
import { decryptSecret } from '../secrets/encryption';
|
|
42
44
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
45
|
+
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
43
46
|
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
44
47
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
45
48
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
@@ -219,6 +222,26 @@ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefin
|
|
|
219
222
|
return undefined;
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Does this module's manifest declare `capabilityName`, under `requires` or
|
|
227
|
+
* `optional`?
|
|
228
|
+
*
|
|
229
|
+
* Reads the manifest stored at import rather than the one on disk, so the
|
|
230
|
+
* answer is the one the operator's `module import` actually validated.
|
|
231
|
+
*/
|
|
232
|
+
function consumerDeclares(db: DbClient, moduleId: string, capabilityName: string): boolean {
|
|
233
|
+
const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
234
|
+
const manifest = row?.manifestData;
|
|
235
|
+
if (!manifest) return false;
|
|
236
|
+
|
|
237
|
+
type Declarations = { capabilities?: { name?: string }[] } | undefined;
|
|
238
|
+
const declared = [
|
|
239
|
+
...((manifest.requires as Declarations)?.capabilities ?? []),
|
|
240
|
+
...((manifest.optional as Declarations)?.capabilities ?? []),
|
|
241
|
+
];
|
|
242
|
+
return declared.some((cap) => cap?.name === capabilityName);
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
export async function loadCapabilityFunctions(
|
|
223
246
|
consumingModuleId: string,
|
|
224
247
|
db: DbClient,
|
|
@@ -380,6 +403,11 @@ export async function loadCapabilityFunctions(
|
|
|
380
403
|
// which made a module-provided capability structurally unable to
|
|
381
404
|
// offer `unregisterRoutes()`-style methods.
|
|
382
405
|
consumerModuleId: consumingModuleId,
|
|
406
|
+
// WHERE THE CALLER'S BYTES ARE, for the same reason and by the same
|
|
407
|
+
// seam. `private_web` and `external_web` publish static sites too, and
|
|
408
|
+
// without this they could not find the caller's web root now that
|
|
409
|
+
// `sourceDir` has left the request (D10 amendment).
|
|
410
|
+
consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
383
411
|
});
|
|
384
412
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
385
413
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -563,6 +591,9 @@ export async function loadCapabilityFunctions(
|
|
|
563
591
|
providerByCapability.set(provider.capabilityName, provider.moduleId);
|
|
564
592
|
result.public_web = createPublicWeb({
|
|
565
593
|
moduleId: consumingModuleId,
|
|
594
|
+
// Resolved here, not in the capability: on a converge there is no
|
|
595
|
+
// consumer process to ask (D10 amendment).
|
|
596
|
+
webRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
566
597
|
logger,
|
|
567
598
|
config: providerConfig,
|
|
568
599
|
secrets: providerSecrets,
|
|
@@ -574,6 +605,20 @@ export async function loadCapabilityFunctions(
|
|
|
574
605
|
caddyModuleId: provider.moduleId,
|
|
575
606
|
dnsManagedDomains,
|
|
576
607
|
dnsRegistrarModuleId,
|
|
608
|
+
// Design D10: the bytes move through the provider's Ansible converge,
|
|
609
|
+
// not a hand-built ssh tar pipe. Core implements it over
|
|
610
|
+
// executeAnsible against the provider's generated project (task 4.3);
|
|
611
|
+
// awaited with everything else, so a publish returns only once the
|
|
612
|
+
// host matches. A failure here fails the deploy loudly — a publish
|
|
613
|
+
// that reports ready while the host never received the bytes is the
|
|
614
|
+
// exact "served but silently unreachable" anti-pattern.
|
|
615
|
+
convergeStaticContent: async () => {
|
|
616
|
+
const { convergeStaticContent } = await import('../services/static-content-converge');
|
|
617
|
+
const result = await convergeStaticContent(db, provider.moduleId);
|
|
618
|
+
if (!result.success) {
|
|
619
|
+
throw new Error(result.error ?? 'static-content converge failed');
|
|
620
|
+
}
|
|
621
|
+
},
|
|
577
622
|
// ISS-0035: register_route/unregister_routes emit this coarse signal
|
|
578
623
|
// instead of SSHing caddy; the caddy provider's reconcile_routes
|
|
579
624
|
// subscription re-renders the Caddyfile from web_routes. We await the
|
|
@@ -641,6 +686,30 @@ export async function loadCapabilityFunctions(
|
|
|
641
686
|
debugLog('public_web: not registered in DB, skipping');
|
|
642
687
|
}
|
|
643
688
|
|
|
689
|
+
// Framework-granted, so unlike everything above there is no provider row to
|
|
690
|
+
// look up and no script to import — celilo IS the management server whose
|
|
691
|
+
// principals these are (web-ui-console D7b).
|
|
692
|
+
//
|
|
693
|
+
// Gated on the DECLARATION, which the rest of this function deliberately is
|
|
694
|
+
// not: the loop injects every capability it can build "not just required
|
|
695
|
+
// ones", so a module that never asked still gets `idp` and `firewall`. That is
|
|
696
|
+
// fine for a capability whose worst outcome is an unused OIDC client. This one
|
|
697
|
+
// mints an SSH principal into celilo's own control plane, so the `requires`
|
|
698
|
+
// line is the authorization and a module that did not write one does not get
|
|
699
|
+
// the object at all.
|
|
700
|
+
if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) {
|
|
701
|
+
// Wrapped like every other capability, so an enrolment that fails mid-deploy
|
|
702
|
+
// leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than
|
|
703
|
+
// only a thrown error further up. The `defineCapabilityFunction` path wraps
|
|
704
|
+
// itself; a framework-built table has to be wrapped here.
|
|
705
|
+
result.control_plane_api = wrapWithLogging(
|
|
706
|
+
buildControlPlaneApi(consumingModuleId),
|
|
707
|
+
logger,
|
|
708
|
+
'control_plane_api',
|
|
709
|
+
);
|
|
710
|
+
debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`);
|
|
711
|
+
}
|
|
712
|
+
|
|
644
713
|
// celilo#1072: the CALL is the binding, not the resolution. Everything above
|
|
645
714
|
// is injected whether or not the consumer declared it — the loop's own
|
|
646
715
|
// comment says "not just required ones" — so recording what was resolved
|
|
@@ -1104,20 +1173,19 @@ async function buildFirewallChain(
|
|
|
1104
1173
|
// attributable. Only the layers that render their own ruleset receive it.
|
|
1105
1174
|
const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
|
|
1106
1175
|
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
unknown
|
|
1112
|
-
>;
|
|
1113
|
-
return data.has_external === true;
|
|
1114
|
-
});
|
|
1176
|
+
// The delegation order, most downstream first. Shared with the console's
|
|
1177
|
+
// closure so the wiring and the picture cannot disagree about which firewall
|
|
1178
|
+
// stands on which — see `orderFirewallChain`.
|
|
1179
|
+
const chainOrder = orderFirewallChain(allProviders);
|
|
1115
1180
|
|
|
1116
|
-
if (
|
|
1181
|
+
if (chainOrder.length === 0) {
|
|
1117
1182
|
debugLog('firewall chain: no provider with external interface found');
|
|
1118
1183
|
return { chain: null, self: null };
|
|
1119
1184
|
}
|
|
1120
1185
|
|
|
1186
|
+
// The leaf is the upstream end: the provider with the direct internet leg.
|
|
1187
|
+
const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number];
|
|
1188
|
+
|
|
1121
1189
|
// Build the leaf (external) firewall first
|
|
1122
1190
|
const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
|
|
1123
1191
|
if (!leafModule) return { chain: null, self: null };
|
|
@@ -1173,7 +1241,10 @@ async function buildFirewallChain(
|
|
|
1173
1241
|
// Build downstream providers, wiring each to the upstream. iptables
|
|
1174
1242
|
// remains a legacy factory because its second arg (upstreamFirewall)
|
|
1175
1243
|
// doesn't fit defineCapabilityFunction's single-context shape.
|
|
1176
|
-
|
|
1244
|
+
// Back into wiring order: each layer is built taking the previous one as its
|
|
1245
|
+
// upstream, so the build runs from the leaf outwards while the chain reads
|
|
1246
|
+
// from the consumer inwards.
|
|
1247
|
+
const downstream = chainOrder.slice(0, -1).reverse();
|
|
1177
1248
|
|
|
1178
1249
|
// Each layer as its OWN provider sees it, so `on_consumer_removed` converges
|
|
1179
1250
|
// the firewall that declares the hook rather than whichever layer a consumer
|
package/src/hooks/executor.ts
CHANGED
|
@@ -31,17 +31,19 @@
|
|
|
31
31
|
* exists (a Mac today, task 4.8) the hook runs unjailed and the mode is
|
|
32
32
|
* RECORDED, so a host that quietly stops jailing is visible rather than silent.
|
|
33
33
|
*
|
|
34
|
-
* Hooks still do NOT execute on the target machine
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* Hooks still do NOT execute on the target machine — a remote operation is
|
|
35
|
+
* initiated from the celilo host. **Stage 3 has landed and scopes that
|
|
36
|
+
* reachability by the credential (design D12):** `~/.ssh` is not bound into
|
|
37
|
+
* the jail, so a hand-built `ssh` cannot authenticate, and the remote
|
|
38
|
+
* primitives cross to the remote-ops broker (`remote-broker.ts`), which holds
|
|
39
|
+
* the key and checks every target against the policy in
|
|
40
|
+
* `services/remote-access.ts` — the module's own systems, or the module's own
|
|
41
|
+
* credential.
|
|
39
42
|
*
|
|
40
43
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
46
|
import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
44
|
-
import { homedir } from 'node:os';
|
|
45
47
|
import { dirname, join, resolve } from 'node:path';
|
|
46
48
|
import {
|
|
47
49
|
type DeployedSystem,
|
|
@@ -59,8 +61,10 @@ import { isPrivilegedCapability } from '../manifest/validate';
|
|
|
59
61
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
60
62
|
import { startBroker } from './broker';
|
|
61
63
|
import {
|
|
64
|
+
HOOK_MOUNT_SET_ENV,
|
|
62
65
|
HOOK_PROTOCOL_VERSION,
|
|
63
66
|
HOOK_PROTOCOL_VERSION_ENV,
|
|
67
|
+
HOOK_REMOTE_SOCKET_ENV,
|
|
64
68
|
HOOK_SOCKET_ENV,
|
|
65
69
|
createLineReader,
|
|
66
70
|
deserializeError,
|
|
@@ -74,8 +78,15 @@ import {
|
|
|
74
78
|
recordJailMode,
|
|
75
79
|
runtimeModulePathsFor,
|
|
76
80
|
} from './jail';
|
|
77
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
type DeclaredPathInput,
|
|
83
|
+
type MountSet,
|
|
84
|
+
type MountSetRequest,
|
|
85
|
+
deriveMountSet,
|
|
86
|
+
} from './mount-set';
|
|
87
|
+
import { type RemoteAccessPolicy, startRemoteBroker } from './remote-broker';
|
|
78
88
|
import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
|
|
89
|
+
import { mountSetEnvValue } from './unjailed-lint';
|
|
79
90
|
|
|
80
91
|
/** Default total timeout: 60 seconds */
|
|
81
92
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
@@ -160,6 +171,14 @@ const FORWARDED_ENV = [
|
|
|
160
171
|
|
|
161
172
|
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
162
173
|
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
174
|
+
/**
|
|
175
|
+
* What celilo actually SPAWNS: the entry that installs the advisory lint
|
|
176
|
+
* before the runner shim's own import graph can load `node:fs` (task 4.7).
|
|
177
|
+
* The runner path above is still what the mount set binds — the entry and the
|
|
178
|
+
* shim live in the same directory and resolve through the same
|
|
179
|
+
* `node_modules`.
|
|
180
|
+
*/
|
|
181
|
+
const HOOK_RUNNER_SPAWN_PATH = join(import.meta.dir, 'hook-runner-entry.ts');
|
|
163
182
|
|
|
164
183
|
/**
|
|
165
184
|
* Validate hook inputs against a contract signature.
|
|
@@ -265,6 +284,13 @@ export interface ExecuteHookOptions {
|
|
|
265
284
|
idleTimeoutMs?: number;
|
|
266
285
|
/** What to jail this run with. Absent runs the hook unjailed. */
|
|
267
286
|
jail?: HookJailInputs;
|
|
287
|
+
/**
|
|
288
|
+
* D12's target policy for this run's remote operations, built by the caller
|
|
289
|
+
* (`services/remote-access.ts`) so the executor stays decoupled from the
|
|
290
|
+
* database the same way it is for `systems`. Absent means every
|
|
291
|
+
* fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
|
|
292
|
+
*/
|
|
293
|
+
remoteAccess?: RemoteAccessPolicy;
|
|
268
294
|
}
|
|
269
295
|
|
|
270
296
|
/**
|
|
@@ -308,12 +334,27 @@ export async function executeHookScript(
|
|
|
308
334
|
logger,
|
|
309
335
|
onActivity: markActive,
|
|
310
336
|
});
|
|
337
|
+
let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
|
|
311
338
|
|
|
312
339
|
try {
|
|
340
|
+
// The remote-ops socket lives beside the capability socket, so the jail's
|
|
341
|
+
// one socket-directory mount covers both channels (design D12).
|
|
342
|
+
const roots = remoteRoots(context, options.jail);
|
|
343
|
+
remoteBroker = await startRemoteBroker({
|
|
344
|
+
socketDir: dirname(broker.socketPath),
|
|
345
|
+
policy: options.remoteAccess,
|
|
346
|
+
readableRoots: roots.readable,
|
|
347
|
+
writableRoots: roots.writable,
|
|
348
|
+
logger,
|
|
349
|
+
onActivity: markActive,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const mountSet = options.jail
|
|
353
|
+
? deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath)))
|
|
354
|
+
: undefined;
|
|
313
355
|
const jail = planJailedSpawn(
|
|
314
|
-
[process.execPath,
|
|
315
|
-
|
|
316
|
-
deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath))),
|
|
356
|
+
[process.execPath, HOOK_RUNNER_SPAWN_PATH],
|
|
357
|
+
mountSet,
|
|
317
358
|
detectJailBackend(),
|
|
318
359
|
jailPolicy(),
|
|
319
360
|
);
|
|
@@ -326,7 +367,13 @@ export async function executeHookScript(
|
|
|
326
367
|
|
|
327
368
|
const child = Bun.spawn({
|
|
328
369
|
cmd: [...jail.cmd],
|
|
329
|
-
env: hookChildEnv(
|
|
370
|
+
env: hookChildEnv(
|
|
371
|
+
broker.socketPath,
|
|
372
|
+
remoteBroker.socketPath,
|
|
373
|
+
// The lint rides only on an unjailed run — its presence in the child's
|
|
374
|
+
// environment is the shim's signal to install it (task 4.7).
|
|
375
|
+
jail.mode === 'unjailed' ? mountSet : undefined,
|
|
376
|
+
),
|
|
330
377
|
stdout: 'pipe',
|
|
331
378
|
stderr: 'pipe',
|
|
332
379
|
});
|
|
@@ -346,8 +393,10 @@ export async function executeHookScript(
|
|
|
346
393
|
killedFor = reason;
|
|
347
394
|
// Refuse capability calls FIRST. A hook between `kill` and its own death
|
|
348
395
|
// can still have a call in flight, and answering it is exactly the
|
|
349
|
-
// failure this replaces.
|
|
396
|
+
// failure this replaces. The remote channel refuses for the same reason:
|
|
397
|
+
// a killed hook must not go on reaching systems (celilo#1003, D12).
|
|
350
398
|
broker.stop();
|
|
399
|
+
remoteBroker?.stop();
|
|
351
400
|
child.kill('SIGTERM');
|
|
352
401
|
setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
|
|
353
402
|
};
|
|
@@ -398,10 +447,41 @@ export async function executeHookScript(
|
|
|
398
447
|
if (!outcome.ok) throw deserializeError(outcome.error);
|
|
399
448
|
return outcome.outputs;
|
|
400
449
|
} finally {
|
|
450
|
+
// Remote first: its socket file lives inside the directory the capability
|
|
451
|
+
// broker's close removes.
|
|
452
|
+
remoteBroker?.close();
|
|
401
453
|
broker.close();
|
|
402
454
|
}
|
|
403
455
|
}
|
|
404
456
|
|
|
457
|
+
/**
|
|
458
|
+
* The local directories this run's stream primitives may touch (design D12).
|
|
459
|
+
*
|
|
460
|
+
* Derived from the same inputs as the jail's mount set, which is what keeps
|
|
461
|
+
* the two views from drifting: everything writable here is writable there,
|
|
462
|
+
* and a path outside these roots is a path the jailed hook could not see
|
|
463
|
+
* anyway — the broker writing to it would produce a file the hook cannot
|
|
464
|
+
* read, reported as success.
|
|
465
|
+
*
|
|
466
|
+
* Planning function (Rule 10.4) — pure.
|
|
467
|
+
*/
|
|
468
|
+
function remoteRoots(
|
|
469
|
+
context: HookContext,
|
|
470
|
+
jail: HookJailInputs | undefined,
|
|
471
|
+
): { readable: string[]; writable: string[] } {
|
|
472
|
+
const writable: string[] = [context.stateDir, context.screenshotDir];
|
|
473
|
+
const readable: string[] = [];
|
|
474
|
+
if (jail) {
|
|
475
|
+
readable.push(jail.modulePath);
|
|
476
|
+
writable.push(join(jail.modulePath, 'generated'));
|
|
477
|
+
for (const input of jail.pathInputs) {
|
|
478
|
+
(input.access === 'write' ? writable : readable).push(input.value);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Whatever a hook may write, it may also read back.
|
|
482
|
+
return { readable: [...readable, ...writable], writable };
|
|
483
|
+
}
|
|
484
|
+
|
|
405
485
|
/**
|
|
406
486
|
* The child's environment, built from an allow-list (design D5).
|
|
407
487
|
*
|
|
@@ -422,7 +502,11 @@ export async function executeHookScript(
|
|
|
422
502
|
*
|
|
423
503
|
* Planning function (Rule 10.4) — pure.
|
|
424
504
|
*/
|
|
425
|
-
export function hookChildEnv(
|
|
505
|
+
export function hookChildEnv(
|
|
506
|
+
socketPath: string,
|
|
507
|
+
remoteSocketPath: string,
|
|
508
|
+
mountSet: MountSet | undefined,
|
|
509
|
+
): Record<string, string> {
|
|
426
510
|
const env: Record<string, string> = {};
|
|
427
511
|
|
|
428
512
|
for (const name of FORWARDED_ENV) {
|
|
@@ -431,6 +515,10 @@ export function hookChildEnv(socketPath: string): Record<string, string> {
|
|
|
431
515
|
}
|
|
432
516
|
|
|
433
517
|
env[HOOK_SOCKET_ENV] = socketPath;
|
|
518
|
+
env[HOOK_REMOTE_SOCKET_ENV] = remoteSocketPath;
|
|
519
|
+
// The unjailed advisory lint's input (task 4.7). Absent when jailed — the
|
|
520
|
+
// shim installs the lint when and only when this variable is set.
|
|
521
|
+
if (mountSet) env[HOOK_MOUNT_SET_ENV] = mountSetEnvValue(mountSet);
|
|
434
522
|
env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
|
|
435
523
|
// Forwarded when the operator set it, not synthesised from `debug` — a hook
|
|
436
524
|
// reads the flag off `ctx.debug`, which crosses in the context frame.
|
|
@@ -465,10 +553,6 @@ function jailRequest(
|
|
|
465
553
|
runnerPath: HOOK_RUNNER_PATH,
|
|
466
554
|
runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
|
|
467
555
|
pathInputs: inputs.pathInputs,
|
|
468
|
-
// Stage 2 only. `remote.ts` still runs inside the hook and needs the key;
|
|
469
|
-
// stage 3 brokers those calls and drops this row (D9, D12). Dropping it
|
|
470
|
-
// early hardens nothing — it stops every hook reaching its own systems.
|
|
471
|
-
sshDir: join(homedir(), '.ssh'),
|
|
472
556
|
};
|
|
473
557
|
}
|
|
474
558
|
|
|
@@ -544,6 +628,14 @@ export interface InvokeHookOptions {
|
|
|
544
628
|
* manifest hook's own `timeout`, being the more specific declaration.
|
|
545
629
|
*/
|
|
546
630
|
timeoutMs?: number;
|
|
631
|
+
/**
|
|
632
|
+
* D12's target policy for the hook's remote operations, built by the caller
|
|
633
|
+
* via `remoteAccessPolicy(moduleId, db)` — the same caller-loads-it pattern
|
|
634
|
+
* as `systems`, and for the same reason: the executor stays decoupled from
|
|
635
|
+
* the database. Absent means every fleet-credential remote operation is
|
|
636
|
+
* refused (Rule 6.4).
|
|
637
|
+
*/
|
|
638
|
+
remoteAccess?: RemoteAccessPolicy;
|
|
547
639
|
}
|
|
548
640
|
|
|
549
641
|
/**
|
|
@@ -846,6 +938,7 @@ export async function invokeHook(
|
|
|
846
938
|
timeoutMs,
|
|
847
939
|
idleTimeoutMs,
|
|
848
940
|
jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
|
|
941
|
+
remoteAccess: options.remoteAccess,
|
|
849
942
|
});
|
|
850
943
|
|
|
851
944
|
// Validate outputs against the contract signature
|