@celilo/cli 1.7.0 → 1.9.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 +3 -0
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +182 -9
- package/src/hooks/broker.test.ts +152 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/executor.ts +234 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/import.ts +19 -1
- package/src/module/packaging/module-state-directory.test.ts +99 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +96 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +129 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +68 -7
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/consumer-cleanup.ts +13 -7
- package/src/services/dns-internal-records.test.ts +74 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +8 -4
- package/src/services/port-forwards.ts +0 -11
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/test-utils/setup-test-db.ts +0 -80
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from '../db/schema';
|
|
41
41
|
import { decryptSecret } from '../secrets/encryption';
|
|
42
42
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
43
|
+
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
43
44
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
44
45
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
45
46
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
@@ -224,6 +225,11 @@ export async function loadCapabilityFunctions(
|
|
|
224
225
|
logger: HookLogger,
|
|
225
226
|
): Promise<Record<string, unknown>> {
|
|
226
227
|
const result: Record<string, unknown> = {};
|
|
228
|
+
// Which provider each injected capability came from, so the binding recorded
|
|
229
|
+
// at the return names the provider the consumer actually reached. Provider
|
|
230
|
+
// self-views (`firewall_registry`, `web_routes`) are deliberately absent —
|
|
231
|
+
// a module reading its own registry has not bound to anything.
|
|
232
|
+
const providerByCapability = new Map<string, string>();
|
|
227
233
|
|
|
228
234
|
const masterKey = await getOrCreateMasterKey();
|
|
229
235
|
|
|
@@ -270,6 +276,11 @@ export async function loadCapabilityFunctions(
|
|
|
270
276
|
);
|
|
271
277
|
if (chain) {
|
|
272
278
|
result[capName] = chain;
|
|
279
|
+
// The consumer talks to the OUTERMOST layer, which is not necessarily
|
|
280
|
+
// the edge provider. Every layer is `stampProvider`'d on the way out,
|
|
281
|
+
// so the one it was handed names itself.
|
|
282
|
+
const chainProvider = (chain as { providerModuleId?: string }).providerModuleId;
|
|
283
|
+
if (chainProvider) providerByCapability.set(capName, chainProvider);
|
|
273
284
|
}
|
|
274
285
|
// The chain hands a CONSUMER the innermost layer, which is not this
|
|
275
286
|
// provider's own layer when it sits further out. `on_consumer_removed`
|
|
@@ -377,6 +388,7 @@ export async function loadCapabilityFunctions(
|
|
|
377
388
|
// greenwave — true, useless, and a violation of the contract's
|
|
378
389
|
// requirement to name the provider.
|
|
379
390
|
result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId));
|
|
391
|
+
providerByCapability.set(capName, capability.moduleId);
|
|
380
392
|
debugLog(`${capName}: loaded via defineCapabilityFunction`);
|
|
381
393
|
continue;
|
|
382
394
|
}
|
|
@@ -403,6 +415,7 @@ export async function loadCapabilityFunctions(
|
|
|
403
415
|
result[capName] = withLedger(
|
|
404
416
|
wrapWithLogging(capabilityInterface as object, logger, capName),
|
|
405
417
|
);
|
|
418
|
+
providerByCapability.set(capName, capability.moduleId);
|
|
406
419
|
debugLog(`${capName}: loaded via legacy factory`);
|
|
407
420
|
// Sole firewall provider running its OWN hook: the interface just built
|
|
408
421
|
// IS its layer, so hand it back under the provider-view name too.
|
|
@@ -544,6 +557,10 @@ export async function loadCapabilityFunctions(
|
|
|
544
557
|
}
|
|
545
558
|
|
|
546
559
|
try {
|
|
560
|
+
// The name comes off the row rather than being restated here: core
|
|
561
|
+
// naming a capability is what `no-module-business-in-core` Scan B counts,
|
|
562
|
+
// and this is bookkeeping, not a branch.
|
|
563
|
+
providerByCapability.set(provider.capabilityName, provider.moduleId);
|
|
547
564
|
result.public_web = createPublicWeb({
|
|
548
565
|
moduleId: consumingModuleId,
|
|
549
566
|
logger,
|
|
@@ -610,9 +627,12 @@ export async function loadCapabilityFunctions(
|
|
|
610
627
|
// — symmetric with how consumers get the public_web capability. Consumers
|
|
611
628
|
// never see this; only the provider does.
|
|
612
629
|
if (consumingModuleId === provider.moduleId) {
|
|
630
|
+
// Async because the consumer is a hook, which now runs in its own
|
|
631
|
+
// process. `routeOps` itself stays synchronous: it is bun:sqlite and it
|
|
632
|
+
// is called in-process by `createPublicWeb`.
|
|
613
633
|
const routeView: RouteReadView = {
|
|
614
|
-
getAllRoutes: () => routeOps.getAllRoutes(),
|
|
615
|
-
getRoutes: (m: string) => routeOps.getRoutes(m),
|
|
634
|
+
getAllRoutes: async () => routeOps.getAllRoutes(),
|
|
635
|
+
getRoutes: async (m: string) => routeOps.getRoutes(m),
|
|
616
636
|
};
|
|
617
637
|
result.web_routes = routeView;
|
|
618
638
|
debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`);
|
|
@@ -621,6 +641,32 @@ export async function loadCapabilityFunctions(
|
|
|
621
641
|
debugLog('public_web: not registered in DB, skipping');
|
|
622
642
|
}
|
|
623
643
|
|
|
644
|
+
// celilo#1072: the CALL is the binding, not the resolution. Everything above
|
|
645
|
+
// is injected whether or not the consumer declared it — the loop's own
|
|
646
|
+
// comment says "not just required ones" — so recording what was resolved
|
|
647
|
+
// would name every provider on the fleet. A method invocation is the only
|
|
648
|
+
// event that separates the optional capability a module uses from the ones it
|
|
649
|
+
// merely declares.
|
|
650
|
+
for (const [capName, iface] of Object.entries(result)) {
|
|
651
|
+
const providerModuleId = providerByCapability.get(capName);
|
|
652
|
+
// A module that provides and consumes the same capability is not bound to
|
|
653
|
+
// itself, and a provider reading its own registry is not a consumer.
|
|
654
|
+
if (!providerModuleId || providerModuleId === consumingModuleId) continue;
|
|
655
|
+
if (!iface || typeof iface !== 'object') continue;
|
|
656
|
+
result[capName] = withBindingRecord(iface as object, () => {
|
|
657
|
+
try {
|
|
658
|
+
recordCapabilityBinding(db, consumingModuleId, capName, providerModuleId);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
// Bookkeeping must never fail the call it is observing. Loud, not silent.
|
|
661
|
+
logger.warn(
|
|
662
|
+
`Could not record the ${capName} binding ${consumingModuleId} → ${providerModuleId}: ${
|
|
663
|
+
error instanceof Error ? error.message : String(error)
|
|
664
|
+
}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
624
670
|
return result;
|
|
625
671
|
}
|
|
626
672
|
|
|
@@ -674,6 +720,11 @@ function buildCapabilityInterface(
|
|
|
674
720
|
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
675
721
|
controlPlaneSubnet: zones?.controlPlaneSubnet,
|
|
676
722
|
frontedSubnets: zones?.frontedSubnets ?? [],
|
|
723
|
+
// Operator settings, forwarded verbatim. `parseStoredConfigValue`
|
|
724
|
+
// preserves each manifest-declared type, so the boolean arrives as a
|
|
725
|
+
// boolean and needs no coercion here.
|
|
726
|
+
defaultRouteZone: config.default_route_zone as string | undefined,
|
|
727
|
+
isolateTransitNetwork: config.isolate_transit_network as boolean | undefined,
|
|
677
728
|
},
|
|
678
729
|
store,
|
|
679
730
|
undefined, // no upstream — the chain path handles that
|
|
@@ -1191,6 +1242,12 @@ async function buildFirewallChain(
|
|
|
1191
1242
|
// Absent means the box has never converged cleanly, so an interface
|
|
1192
1243
|
// celilo cannot attribute refuses rather than being disabled.
|
|
1193
1244
|
interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline),
|
|
1245
|
+
// Which declared zone carries this firewall's default route, and
|
|
1246
|
+
// whether fronted zones may initiate into it. Both are operator
|
|
1247
|
+
// settings and both are read HERE or nowhere: the module declares them
|
|
1248
|
+
// on `FirewallConfig`, and nothing else in celilo constructs one.
|
|
1249
|
+
defaultRouteZone: provConfig.default_route_zone as string | undefined,
|
|
1250
|
+
isolateTransitNetwork: provConfig.isolate_transit_network as boolean | undefined,
|
|
1194
1251
|
},
|
|
1195
1252
|
store,
|
|
1196
1253
|
currentUpstream,
|
package/src/hooks/executor.ts
CHANGED
|
@@ -7,14 +7,28 @@
|
|
|
7
7
|
* - Output validation
|
|
8
8
|
* - Structured logging
|
|
9
9
|
*
|
|
10
|
-
* **Execution model:** hook
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* **Execution model:** a hook script is a program celilo RUNS, not a library
|
|
11
|
+
* celilo loads. `executeHookScript` spawns `bun hook-runner.ts <script>` and
|
|
12
|
+
* becomes a broker: it keeps the database, the master key and the live
|
|
13
|
+
* capability objects, and answers the child over a versioned NDJSON protocol
|
|
14
|
+
* on a Unix socket (`hook-protocol.ts`, `broker.ts`). See
|
|
15
|
+
* `openspec/changes/hook-process-boundary/design.md`.
|
|
16
|
+
*
|
|
17
|
+
* Three things follow, and they are the point:
|
|
18
|
+
*
|
|
19
|
+
* - The child's environment is an ALLOW-LIST, not `...process.env`. celilo's
|
|
20
|
+
* own paths and the operator's tokens stop being visible (design D5).
|
|
21
|
+
* - A timeout is a real `proc.kill()`. The old implementation raced the
|
|
22
|
+
* hook's promise against a timer and cancelled nothing, so a hook that
|
|
23
|
+
* "timed out" kept its capability objects and went on registering DNS
|
|
24
|
+
* records after celilo reported the deploy failed (celilo#1003).
|
|
25
|
+
* - A hook's memory is its own process's, not celilo's heap.
|
|
26
|
+
*
|
|
27
|
+
* Hooks still do NOT execute on the target machine. One that needs to touch a
|
|
28
|
+
* target initiates SSH outbound itself, so anything it depends on (chromium,
|
|
17
29
|
* system binaries, credentials) must be available on the celilo CLI host.
|
|
30
|
+
* Stage 2 of the change above jails the filesystem and stage 3 scopes that
|
|
31
|
+
* reachability; neither has landed.
|
|
18
32
|
*
|
|
19
33
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
20
34
|
*/
|
|
@@ -23,7 +37,6 @@ import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs
|
|
|
23
37
|
import { dirname, join, resolve } from 'node:path';
|
|
24
38
|
import {
|
|
25
39
|
type DeployedSystem,
|
|
26
|
-
isCompiledHook,
|
|
27
40
|
isMissingProviderInputError,
|
|
28
41
|
moduleArtifactDir,
|
|
29
42
|
} from '@celilo/capabilities';
|
|
@@ -35,6 +48,14 @@ import {
|
|
|
35
48
|
} from '../manifest/contracts';
|
|
36
49
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
37
50
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
51
|
+
import { startBroker } from './broker';
|
|
52
|
+
import {
|
|
53
|
+
HOOK_PROTOCOL_VERSION,
|
|
54
|
+
HOOK_PROTOCOL_VERSION_ENV,
|
|
55
|
+
HOOK_SOCKET_ENV,
|
|
56
|
+
createLineReader,
|
|
57
|
+
deserializeError,
|
|
58
|
+
} from './hook-protocol';
|
|
38
59
|
import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
|
|
39
60
|
|
|
40
61
|
/** Default total timeout: 60 seconds */
|
|
@@ -51,6 +72,76 @@ const DEFAULT_TIMEOUT_MS = 60_000;
|
|
|
51
72
|
*/
|
|
52
73
|
const IDLE_TIMEOUT_MS = 30_000;
|
|
53
74
|
|
|
75
|
+
/** How often the idle tracker looks. Unchanged from the promise-based timer. */
|
|
76
|
+
const IDLE_POLL_MS = 5_000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SIGTERM is a request. A hook that traps it and keeps working would leave the
|
|
80
|
+
* boundary buying nothing over the promise race it replaced, so SIGKILL
|
|
81
|
+
* follows. Short, because by this point celilo has already told the operator
|
|
82
|
+
* the hook failed.
|
|
83
|
+
*/
|
|
84
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Environment variables a hook inherits. Everything else is dropped (D5).
|
|
88
|
+
*
|
|
89
|
+
* Two kinds, and the distinction is the whole rule.
|
|
90
|
+
*
|
|
91
|
+
* **How to run** — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`. A hook that spawns
|
|
92
|
+
* anything needs these, and withholding them hides no path.
|
|
93
|
+
*
|
|
94
|
+
* **How to reach the network, and whom to trust** — the CA and proxy families.
|
|
95
|
+
* These configure the RUNTIME, not the module: they say which certificate
|
|
96
|
+
* authorities this host trusts and how it reaches the internet. They name no
|
|
97
|
+
* secret, they are not module-specific, and withholding them reduces a hook's
|
|
98
|
+
* authority by nothing at all. It only makes its TLS connections wrong.
|
|
99
|
+
*
|
|
100
|
+
* `NODE_EXTRA_CA_CERTS` is why this second group exists rather than being an
|
|
101
|
+
* afterthought. `packages/e2e/docker/Dockerfile.management:107` sets it because
|
|
102
|
+
* `bun fetch()` otherwise ignores `/etc/ssl/certs/ca-certificates.crt`
|
|
103
|
+
* entirely and rejects every certificate the simulated CA issues. Twelve
|
|
104
|
+
* hook-reachable module files make HTTPS requests. Dropping it would have
|
|
105
|
+
* broken all of them, in the rig only, as a TLS error naming no cause.
|
|
106
|
+
*
|
|
107
|
+
* The proxy family is not set anywhere today. It is here because it is the
|
|
108
|
+
* same kind of thing, it costs nothing when absent, and the alternative is
|
|
109
|
+
* discovering it the way `NODE_EXTRA_CA_CERTS` was discovered.
|
|
110
|
+
*
|
|
111
|
+
* **`TF_CLI_CONFIG_FILE` is deliberately NOT here.** It is toolchain
|
|
112
|
+
* configuration too, but no hook invokes the terraform binary — every mention
|
|
113
|
+
* of terraform in a module script is a `generated/terraform/` PATH. celilo runs
|
|
114
|
+
* terraform from its own process, which keeps its own environment. Recorded so
|
|
115
|
+
* the next reader does not have to re-derive it.
|
|
116
|
+
*
|
|
117
|
+
* What is NOT in either group: anything module-specific. A module that needs a
|
|
118
|
+
* value declares it (config) or is handed it (a hook input). See the note on
|
|
119
|
+
* `DDNS_ENDPOINT` in openspec/changes/hook-process-boundary/baseline.md for the
|
|
120
|
+
* one case that got this wrong and what was done about it.
|
|
121
|
+
*/
|
|
122
|
+
const FORWARDED_ENV = [
|
|
123
|
+
// How to run.
|
|
124
|
+
'PATH',
|
|
125
|
+
'HOME',
|
|
126
|
+
'LANG',
|
|
127
|
+
'TZ',
|
|
128
|
+
'TMPDIR',
|
|
129
|
+
// Whom to trust.
|
|
130
|
+
'NODE_EXTRA_CA_CERTS',
|
|
131
|
+
'SSL_CERT_FILE',
|
|
132
|
+
'SSL_CERT_DIR',
|
|
133
|
+
// How to reach the network.
|
|
134
|
+
'HTTP_PROXY',
|
|
135
|
+
'HTTPS_PROXY',
|
|
136
|
+
'NO_PROXY',
|
|
137
|
+
'http_proxy',
|
|
138
|
+
'https_proxy',
|
|
139
|
+
'no_proxy',
|
|
140
|
+
] as const;
|
|
141
|
+
|
|
142
|
+
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
143
|
+
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
144
|
+
|
|
54
145
|
/**
|
|
55
146
|
* Validate hook inputs against a contract signature.
|
|
56
147
|
*
|
|
@@ -148,127 +239,159 @@ export async function executeHookScript(
|
|
|
148
239
|
throw new Error(`Hook script not found: ${scriptPath}`);
|
|
149
240
|
}
|
|
150
241
|
|
|
151
|
-
//
|
|
242
|
+
// Activity is now ANY frame or ANY byte the child writes, rather than only a
|
|
243
|
+
// `ctx.logger` call. That is strictly more generous than the old tracker and
|
|
244
|
+
// it removes a class of false idle-kill: a hook that prints progress to
|
|
245
|
+
// stdout used to look silent (celilo#622 is the same family).
|
|
152
246
|
let lastActivity = Date.now();
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
// Wrap logger to track activity for idle timeout
|
|
156
|
-
const trackedLogger: HookLogger = {
|
|
157
|
-
info(message: string) {
|
|
158
|
-
lastActivity = Date.now();
|
|
159
|
-
originalLogger.info(message);
|
|
160
|
-
},
|
|
161
|
-
warn(message: string) {
|
|
162
|
-
lastActivity = Date.now();
|
|
163
|
-
originalLogger.warn(message);
|
|
164
|
-
},
|
|
165
|
-
error(message: string) {
|
|
166
|
-
lastActivity = Date.now();
|
|
167
|
-
originalLogger.error(message);
|
|
168
|
-
},
|
|
169
|
-
success(message: string) {
|
|
170
|
-
lastActivity = Date.now();
|
|
171
|
-
originalLogger.success(message);
|
|
172
|
-
},
|
|
247
|
+
const markActive = () => {
|
|
248
|
+
lastActivity = Date.now();
|
|
173
249
|
};
|
|
174
250
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
logger: trackedLogger,
|
|
178
|
-
};
|
|
251
|
+
const { logger, ...serializableContext } = context;
|
|
252
|
+
delete (serializableContext as Record<string, unknown>).capabilities;
|
|
179
253
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
const result = await Promise.race(promises);
|
|
254
|
+
const broker = await startBroker({
|
|
255
|
+
capabilities: context.capabilities,
|
|
256
|
+
context: serializableContext as Record<string, unknown>,
|
|
257
|
+
scriptPath,
|
|
258
|
+
logger,
|
|
259
|
+
onActivity: markActive,
|
|
260
|
+
});
|
|
189
261
|
|
|
190
|
-
|
|
191
|
-
|
|
262
|
+
try {
|
|
263
|
+
const child = Bun.spawn({
|
|
264
|
+
cmd: [process.execPath, HOOK_RUNNER_PATH],
|
|
265
|
+
env: hookChildEnv(broker.socketPath),
|
|
266
|
+
stdout: 'pipe',
|
|
267
|
+
stderr: 'pipe',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// The child's stdout and stderr stay what they are — human output. They
|
|
271
|
+
// now reach `ctx.logger` instead of celilo's own stdout, which is the
|
|
272
|
+
// attribution: a `console.log` from an in-process hook landed unlabelled
|
|
273
|
+
// in the CLI's output with nothing saying which hook wrote it.
|
|
274
|
+
const forwarding = Promise.all([
|
|
275
|
+
forwardStream(child.stdout, logger.info.bind(logger), markActive),
|
|
276
|
+
forwardStream(child.stderr, logger.warn.bind(logger), markActive),
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
let killedFor: string | null = null;
|
|
280
|
+
const kill = (reason: string) => {
|
|
281
|
+
if (killedFor) return;
|
|
282
|
+
killedFor = reason;
|
|
283
|
+
// Refuse capability calls FIRST. A hook between `kill` and its own death
|
|
284
|
+
// can still have a call in flight, and answering it is exactly the
|
|
285
|
+
// failure this replaces.
|
|
286
|
+
broker.stop();
|
|
287
|
+
child.kill('SIGTERM');
|
|
288
|
+
setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
|
|
289
|
+
};
|
|
192
290
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
291
|
+
const totalTimer = setTimeout(() => kill('total'), timeoutMs);
|
|
292
|
+
// Debug runs get no idle timer, same as before: an operator stepping
|
|
293
|
+
// through a browser hook is silent for minutes on purpose.
|
|
294
|
+
const idleTimer = context.debug
|
|
295
|
+
? undefined
|
|
296
|
+
: setInterval(() => {
|
|
297
|
+
if (Date.now() - lastActivity >= idleTimeoutMs) kill('idle');
|
|
298
|
+
}, IDLE_POLL_MS);
|
|
299
|
+
|
|
300
|
+
let exitCode: number;
|
|
301
|
+
try {
|
|
302
|
+
exitCode = await child.exited;
|
|
303
|
+
// Both channels, not just the process: the terminal frame can still be
|
|
304
|
+
// in the socket buffer when the exit is observed.
|
|
305
|
+
await broker.drained();
|
|
306
|
+
await forwarding;
|
|
307
|
+
} finally {
|
|
308
|
+
clearTimeout(totalTimer);
|
|
309
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
310
|
+
}
|
|
212
311
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
312
|
+
// Process exit is the ONE terminal event, so there is no race to lose and
|
|
313
|
+
// nothing keeps running past this line. That is the difference from the
|
|
314
|
+
// `Promise.race` this replaces (celilo#1003).
|
|
315
|
+
if (killedFor === 'total') {
|
|
316
|
+
throw new Error(`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)`);
|
|
317
|
+
}
|
|
318
|
+
if (killedFor === 'idle') {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
216
323
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
324
|
+
const outcome = broker.outcome();
|
|
325
|
+
if (!outcome) {
|
|
326
|
+
const faults = broker.faults();
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Hook process exited with code ${exitCode} without returning a result.${
|
|
329
|
+
faults.length > 0 ? ` ${faults.join('; ')}` : ''
|
|
330
|
+
}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!outcome.ok) throw deserializeError(outcome.error);
|
|
335
|
+
return outcome.outputs;
|
|
336
|
+
} finally {
|
|
337
|
+
broker.close();
|
|
221
338
|
}
|
|
339
|
+
}
|
|
222
340
|
|
|
223
|
-
|
|
341
|
+
/**
|
|
342
|
+
* The child's environment, built from an allow-list (design D5).
|
|
343
|
+
*
|
|
344
|
+
* NOT `...process.env`. `packages/event-bus/src/dispatcher.ts` spawns handlers
|
|
345
|
+
* that way and it grants a subprocess MORE than the in-process call did — the
|
|
346
|
+
* whole environment plus an explicit database path. Copying that pattern would
|
|
347
|
+
* buy the crash isolation and none of the authority reduction.
|
|
348
|
+
*
|
|
349
|
+
* Measured on the operator's own shell: what a hook inherited before this was
|
|
350
|
+
* 24 variables naming celilo state or carrying a credential, twenty of them
|
|
351
|
+
* third-party API tokens with nothing to do with celilo
|
|
352
|
+
* (`openspec/changes/hook-process-boundary/baseline.md`).
|
|
353
|
+
*
|
|
354
|
+
* This is not the boundary — a hook can still COMPUTE the default master-key
|
|
355
|
+
* path from `$HOME` and read it. It is the cheap half, and it is what stops the
|
|
356
|
+
* dispatcher's mistake repeating here. Stage 2's mount set is what makes the
|
|
357
|
+
* path unreachable.
|
|
358
|
+
*
|
|
359
|
+
* Planning function (Rule 10.4) — pure.
|
|
360
|
+
*/
|
|
361
|
+
export function hookChildEnv(socketPath: string): Record<string, string> {
|
|
362
|
+
const env: Record<string, string> = {};
|
|
224
363
|
|
|
225
|
-
|
|
226
|
-
|
|
364
|
+
for (const name of FORWARDED_ENV) {
|
|
365
|
+
const value = process.env[name];
|
|
366
|
+
if (value !== undefined) env[name] = value;
|
|
227
367
|
}
|
|
228
368
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
369
|
+
env[HOOK_SOCKET_ENV] = socketPath;
|
|
370
|
+
env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
|
|
371
|
+
// Forwarded when the operator set it, not synthesised from `debug` — a hook
|
|
372
|
+
// reads the flag off `ctx.debug`, which crosses in the context frame.
|
|
373
|
+
if (process.env.CELILO_DEBUG !== undefined) env.CELILO_DEBUG = process.env.CELILO_DEBUG;
|
|
232
374
|
|
|
233
|
-
return
|
|
375
|
+
return env;
|
|
234
376
|
}
|
|
235
377
|
|
|
236
|
-
/**
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
378
|
+
/** Read a piped stream line by line into the logger. */
|
|
379
|
+
async function forwardStream(
|
|
380
|
+
stream: ReadableStream<Uint8Array>,
|
|
381
|
+
emit: (line: string) => void,
|
|
382
|
+
markActive: () => void,
|
|
383
|
+
): Promise<void> {
|
|
384
|
+
const decoder = new TextDecoder();
|
|
385
|
+
const feed = createLineReader((line) => {
|
|
386
|
+
markActive();
|
|
387
|
+
emit(line);
|
|
244
388
|
});
|
|
245
|
-
}
|
|
246
389
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
getIdleDuration: () => number,
|
|
253
|
-
idleTimeoutMs: number,
|
|
254
|
-
): Promise<never> {
|
|
255
|
-
return new Promise((_, reject) => {
|
|
256
|
-
const interval = setInterval(() => {
|
|
257
|
-
if (getIdleDuration() >= idleTimeoutMs) {
|
|
258
|
-
clearInterval(interval);
|
|
259
|
-
reject(
|
|
260
|
-
new Error(
|
|
261
|
-
`Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
|
|
262
|
-
),
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
}, 5000);
|
|
266
|
-
|
|
267
|
-
// Don't block process exit
|
|
268
|
-
if (interval.unref) {
|
|
269
|
-
interval.unref();
|
|
270
|
-
}
|
|
271
|
-
});
|
|
390
|
+
for await (const chunk of stream) {
|
|
391
|
+
feed(decoder.decode(chunk, { stream: true }));
|
|
392
|
+
}
|
|
393
|
+
// A final line with no trailing newline would otherwise be dropped.
|
|
394
|
+
feed('\n');
|
|
272
395
|
}
|
|
273
396
|
|
|
274
397
|
export interface InvokeHookOptions {
|