@celilo/cli 1.8.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.
Files changed (58) hide show
  1. package/CELILO_CORE_MODULES.md +2 -0
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/drizzle/0028_capability_bindings.sql +26 -0
  4. package/drizzle/0029_module_instances.sql +58 -0
  5. package/drizzle/meta/_journal.json +14 -0
  6. package/package.json +2 -2
  7. package/src/cli/commands/module-show.ts +1 -0
  8. package/src/db/foreign-keys.test.ts +101 -0
  9. package/src/db/schema.ts +161 -5
  10. package/src/hooks/broker.test.ts +152 -0
  11. package/src/hooks/broker.ts +307 -0
  12. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  13. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  14. package/src/hooks/capability-loader.test.ts +10 -2
  15. package/src/hooks/capability-loader.ts +59 -2
  16. package/src/hooks/executor.ts +234 -111
  17. package/src/hooks/hook-protocol.test.ts +192 -0
  18. package/src/hooks/hook-protocol.ts +275 -0
  19. package/src/hooks/hook-runner.ts +231 -0
  20. package/src/hooks/hook-timeout.test.ts +103 -0
  21. package/src/hooks/hook-trespass.test.ts +201 -0
  22. package/src/hooks/injected-capabilities.test.ts +75 -0
  23. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  24. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  25. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  26. package/src/manifest/validate-provider-views.test.ts +61 -0
  27. package/src/manifest/validate.ts +21 -14
  28. package/src/module/packaging/module-state-directory.test.ts +99 -0
  29. package/src/module/packaging/package-rules.ts +10 -2
  30. package/src/policy/capability-shape-baseline.ts +8 -0
  31. package/src/policy/capability-shape.ts +13 -1
  32. package/src/policy/module-business-baseline.ts +36 -0
  33. package/src/services/alerting/ack.test.ts +2 -2
  34. package/src/services/alerting/deferral.test.ts +2 -2
  35. package/src/services/alerting/delivery-loop.test.ts +2 -2
  36. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  37. package/src/services/alerting/inbound-poller.test.ts +2 -2
  38. package/src/services/alerting/inbound.test.ts +2 -2
  39. package/src/services/alerting/notification-responder.test.ts +2 -2
  40. package/src/services/alerting/run-monitor.test.ts +2 -2
  41. package/src/services/alerting/store.test.ts +2 -2
  42. package/src/services/alerting/sweep-runner.test.ts +2 -2
  43. package/src/services/alerting/tokens.test.ts +2 -2
  44. package/src/services/capability-bindings.test.ts +104 -0
  45. package/src/services/capability-bindings.ts +107 -0
  46. package/src/services/capability-table-rows.test.ts +2 -2
  47. package/src/services/consumer-cleanup.test.ts +40 -3
  48. package/src/services/dns-internal-records.test.ts +3 -3
  49. package/src/services/fleet-checks.test.ts +4 -4
  50. package/src/services/module-instances.test.ts +198 -0
  51. package/src/services/module-instances.ts +96 -0
  52. package/src/services/module-journal.test.ts +2 -2
  53. package/src/services/module-subscriptions.test.ts +1 -1
  54. package/src/services/port-forwards.test.ts +2 -2
  55. package/src/services/trusted-sources.test.ts +3 -3
  56. package/src/templates/ingress-ip.test.ts +31 -0
  57. package/src/test-utils/database.ts +31 -1
  58. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The hook process boundary's wire protocol.
3
+ *
4
+ * A hook runs in its own `bun` process and reaches celilo — the database, the
5
+ * master key, the live capability objects — only through these frames. See
6
+ * `openspec/changes/hook-process-boundary/design.md`, D2 and D3.
7
+ *
8
+ * Newline-delimited JSON over a Unix socket, Zod-validated, in two
9
+ * discriminated unions. Modelled directly on `packages/core/src/protocol.ts`,
10
+ * which does the same job for the remote CLI.
11
+ *
12
+ * **Why a socket and not stdout.** Seventeen module script files spawn
13
+ * subprocesses. A grandchild writing raw bytes to fd 1 would corrupt the frame
14
+ * stream and no JS-level capture prevents it. The remote CLI gets away with
15
+ * stdout because it *translates* every unrecognised line into a log message;
16
+ * here the same line could be half a frame. The child's stdout and stderr stay
17
+ * exactly what they are — human output the parent forwards to the logger.
18
+ *
19
+ * **Where this lives.** Both ends are framework code: the broker in
20
+ * `broker.ts` and the runner shim in `hook-runner.ts`, both under
21
+ * `apps/celilo/src/hooks/`. Neither side imports the other, so the protocol
22
+ * needs no package of its own and `@celilo/capabilities` — which every MODULE
23
+ * imports — stays out of it. A hook script never sees these types.
24
+ */
25
+
26
+ import { z } from 'zod';
27
+
28
+ export const HOOK_PROTOCOL_VERSION = 1;
29
+
30
+ /** Environment variable carrying the broker's socket path to the child. */
31
+ export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
32
+ /** Environment variable carrying the parent's protocol version to the child. */
33
+ export const HOOK_PROTOCOL_VERSION_ENV = 'CELILO_HOOK_PROTOCOL_VERSION';
34
+
35
+ /**
36
+ * An error crossing the boundary.
37
+ *
38
+ * `fields` is what carries `MissingProviderInputError`'s `providerModuleId` /
39
+ * `ensureId` / `value` / `humanContext` (design D6). The framework READS that
40
+ * error rather than displaying it — `invokeHook` inspects it to drive the
41
+ * cross-module ensure interview — so those four fields have to survive the
42
+ * round trip intact. They do, because `isMissingProviderInputError` is
43
+ * duck-typed rather than `instanceof`: it was written that way for a module's
44
+ * bundled copy of `@celilo/capabilities` (celilo#173), and a process is one
45
+ * more of the same boundary.
46
+ */
47
+ export const HookErrorSchema = z.object({
48
+ name: z.string(),
49
+ message: z.string(),
50
+ stack: z.string().optional(),
51
+ fields: z.record(z.unknown()).optional(),
52
+ });
53
+ export type HookError = z.infer<typeof HookErrorSchema>;
54
+
55
+ // ── child → parent ────────────────────────────────────────────────────────
56
+
57
+ /** Handshake. The parent checks the version and refuses a mismatch. */
58
+ export const ReadyFrameSchema = z.object({
59
+ type: z.literal('ready'),
60
+ protocolVersion: z.number().int(),
61
+ });
62
+
63
+ /** A capability method call, correlated with its `return`/`throw` by `id`. */
64
+ export const CallFrameSchema = z.object({
65
+ type: z.literal('call'),
66
+ id: z.string(),
67
+ capability: z.string(),
68
+ method: z.string(),
69
+ args: z.array(z.unknown()),
70
+ });
71
+
72
+ /** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */
73
+ export const LogFrameSchema = z.object({
74
+ type: z.literal('log'),
75
+ level: z.enum(['info', 'warn', 'error', 'success']),
76
+ message: z.string(),
77
+ });
78
+
79
+ /** The hook returned. Terminal. */
80
+ export const ResultFrameSchema = z.object({
81
+ type: z.literal('result'),
82
+ outputs: z.record(z.unknown()),
83
+ });
84
+
85
+ /** The hook threw. Terminal. Distinct from a `throw` answering one `call`. */
86
+ export const HookThrewFrameSchema = z.object({
87
+ type: z.literal('throw'),
88
+ error: HookErrorSchema,
89
+ });
90
+
91
+ export const ChildFrameSchema = z.discriminatedUnion('type', [
92
+ ReadyFrameSchema,
93
+ CallFrameSchema,
94
+ LogFrameSchema,
95
+ ResultFrameSchema,
96
+ HookThrewFrameSchema,
97
+ ]);
98
+ export type ChildFrame = z.infer<typeof ChildFrameSchema>;
99
+
100
+ // ── parent → child ────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Everything in `HookContext` that is plain data: the hook's inputs, `config`,
104
+ * `secrets`, `systems`, `debug`, `screenshotDir`. Not `logger` and not
105
+ * `capabilities` — the shim rebuilds both from frames.
106
+ */
107
+ export const ContextFrameSchema = z.object({
108
+ type: z.literal('context'),
109
+ protocolVersion: z.number().int(),
110
+ scriptPath: z.string(),
111
+ context: z.record(z.unknown()),
112
+ });
113
+
114
+ /**
115
+ * The capability shape descriptor (design D2).
116
+ *
117
+ * The broker does not know what a capability is. It walks the object
118
+ * `loadCapabilityFunctions` returns the way `wrapWithLogging` does:
119
+ * function-valued keys become `methods`, everything else is copied into
120
+ * `data` — which is where `stampProvider`'s `providerModuleId` lives, and a
121
+ * hook reads it to name the provider in an error.
122
+ *
123
+ * An optional method the provider did not implement is simply absent from
124
+ * `methods`, so it is absent on the proxy, so `if (cap.registerTrustedSource)`
125
+ * keeps working with no special case.
126
+ */
127
+ export const CapabilitiesFrameSchema = z.object({
128
+ type: z.literal('capabilities'),
129
+ shape: z.record(
130
+ z.object({
131
+ methods: z.array(z.string()),
132
+ data: z.record(z.unknown()),
133
+ }),
134
+ ),
135
+ });
136
+
137
+ /** A capability call returned. */
138
+ export const ReturnFrameSchema = z.object({
139
+ type: z.literal('return'),
140
+ id: z.string(),
141
+ value: z.unknown(),
142
+ });
143
+
144
+ /** A capability call threw. */
145
+ export const CallThrewFrameSchema = z.object({
146
+ type: z.literal('throw'),
147
+ id: z.string(),
148
+ error: HookErrorSchema,
149
+ });
150
+
151
+ export const ParentFrameSchema = z.discriminatedUnion('type', [
152
+ ContextFrameSchema,
153
+ CapabilitiesFrameSchema,
154
+ ReturnFrameSchema,
155
+ CallThrewFrameSchema,
156
+ ]);
157
+ export type ParentFrame = z.infer<typeof ParentFrameSchema>;
158
+
159
+ /** One capability's entry in the shape descriptor. */
160
+ export type CapabilityShape = z.infer<typeof CapabilitiesFrameSchema>['shape'][string];
161
+
162
+ // ── framing ───────────────────────────────────────────────────────────────
163
+
164
+ export type ParseResult<T> = { ok: true; frame: T } | { ok: false; error: string };
165
+
166
+ /** `JSON.stringify` plus the delimiter. One frame, one line. */
167
+ export function encodeFrame(frame: ChildFrame | ParentFrame): string {
168
+ return `${JSON.stringify(frame)}\n`;
169
+ }
170
+
171
+ /**
172
+ * Parse one line into a frame.
173
+ *
174
+ * Returns the failure as a value rather than throwing it. A malformed line is
175
+ * a hook failure with a readable message, never a parse crash inside celilo:
176
+ * the whole point of the boundary is that the child cannot take the parent
177
+ * down, and a reader that throws would hand that back.
178
+ */
179
+ function parseFrame<T>(schema: z.ZodType<T>, line: string): ParseResult<T> {
180
+ let json: unknown;
181
+ try {
182
+ json = JSON.parse(line);
183
+ } catch {
184
+ return { ok: false, error: `not JSON: ${truncate(line)}` };
185
+ }
186
+ const parsed = schema.safeParse(json);
187
+ if (!parsed.success) {
188
+ return {
189
+ ok: false,
190
+ error: `${parsed.error.issues[0]?.message ?? 'invalid'}: ${truncate(line)}`,
191
+ };
192
+ }
193
+ return { ok: true, frame: parsed.data };
194
+ }
195
+
196
+ export function parseChildFrame(line: string): ParseResult<ChildFrame> {
197
+ return parseFrame(ChildFrameSchema, line);
198
+ }
199
+
200
+ export function parseParentFrame(line: string): ParseResult<ParentFrame> {
201
+ return parseFrame(ParentFrameSchema, line);
202
+ }
203
+
204
+ function truncate(line: string): string {
205
+ return line.length > 120 ? `${line.slice(0, 120)}…` : line;
206
+ }
207
+
208
+ /**
209
+ * Split a byte stream into complete lines, holding the partial tail.
210
+ *
211
+ * Both ends need this and neither can assume a frame arrives in one chunk —
212
+ * a capability payload is easily larger than a socket read.
213
+ */
214
+ export function createLineReader(onLine: (line: string) => void): (chunk: string) => void {
215
+ let buffer = '';
216
+ return (chunk: string) => {
217
+ buffer += chunk;
218
+ let newline = buffer.indexOf('\n');
219
+ while (newline !== -1) {
220
+ const line = buffer.slice(0, newline);
221
+ buffer = buffer.slice(newline + 1);
222
+ if (line.trim() !== '') onLine(line);
223
+ newline = buffer.indexOf('\n');
224
+ }
225
+ };
226
+ }
227
+
228
+ /**
229
+ * The handshake check, in one place so both ends produce the same sentence.
230
+ *
231
+ * Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a
232
+ * module's bundled copy was not), and the operator needs to know which side is
233
+ * which to fix it.
234
+ */
235
+ export function versionMismatch(theirs: number, side: string): string | null {
236
+ if (theirs === HOOK_PROTOCOL_VERSION) return null;
237
+ return `Hook protocol version mismatch: ${side} speaks ${theirs}, this process speaks ${HOOK_PROTOCOL_VERSION}.`;
238
+ }
239
+
240
+ // ── error envelopes ───────────────────────────────────────────────────────
241
+
242
+ /** Fields `MissingProviderInputError` carries and the framework reads (D6). */
243
+ const CARRIED_ERROR_FIELDS = ['providerModuleId', 'ensureId', 'value', 'humanContext'] as const;
244
+
245
+ /** Turn a thrown value into something that survives JSON. */
246
+ export function serializeError(error: unknown): HookError {
247
+ if (!(error instanceof Error)) {
248
+ return { name: 'Error', message: String(error) };
249
+ }
250
+ const source = error as unknown as Record<string, unknown>;
251
+ const fields: Record<string, unknown> = {};
252
+ for (const key of CARRIED_ERROR_FIELDS) {
253
+ if (source[key] !== undefined) fields[key] = source[key];
254
+ }
255
+ return {
256
+ name: error.name,
257
+ message: error.message,
258
+ stack: error.stack,
259
+ ...(Object.keys(fields).length > 0 ? { fields } : {}),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Rebuild a real `Error` so `try`/`catch` inside a hook behaves as it does
265
+ * in-process, and so the framework's duck-typed guards still recognise it.
266
+ */
267
+ export function deserializeError(error: HookError): Error {
268
+ const rebuilt = new Error(error.message);
269
+ rebuilt.name = error.name;
270
+ if (error.stack) rebuilt.stack = error.stack;
271
+ for (const [key, value] of Object.entries(error.fields ?? {})) {
272
+ (rebuilt as unknown as Record<string, unknown>)[key] = value;
273
+ }
274
+ return rebuilt;
275
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The hook runner shim.
3
+ *
4
+ * This is the ONLY thing in celilo that `import()`s a module's hook script,
5
+ * and it runs in its own `bun` process with an allow-listed environment. It
6
+ * connects to the broker's socket, receives the context and the capability
7
+ * shape, rebuilds `HookContext` on this side, invokes the hook, and reports.
8
+ *
9
+ * It is spawned, never imported — `executeHookScript` runs
10
+ * `bun <this file> ` and talks to it over the socket named in
11
+ * `CELILO_HOOK_SOCKET`. Nothing here is exported for that reason.
12
+ *
13
+ * Execution function (Rule 10.1).
14
+ */
15
+
16
+ import { connect } from 'node:net';
17
+ import { isCompiledHook } from '@celilo/capabilities';
18
+ import {
19
+ type CapabilityShape,
20
+ type ChildFrame,
21
+ HOOK_PROTOCOL_VERSION,
22
+ HOOK_SOCKET_ENV,
23
+ createLineReader,
24
+ encodeFrame,
25
+ parseParentFrame,
26
+ serializeError,
27
+ versionMismatch,
28
+ } from './hook-protocol';
29
+ import type { HookContext, HookLogger } from './types';
30
+
31
+ const socketPath = process.env[HOOK_SOCKET_ENV];
32
+ if (!socketPath) {
33
+ process.stderr.write(
34
+ `${HOOK_SOCKET_ENV} is not set; the hook runner is not spawnable directly.\n`,
35
+ );
36
+ process.exit(2);
37
+ }
38
+
39
+ const socket = connect(socketPath);
40
+ socket.setEncoding('utf-8');
41
+
42
+ /** Capability calls in flight, correlated with their answer by id. */
43
+ const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
44
+ let nextCallId = 0;
45
+
46
+ let contextData: Record<string, unknown> | undefined;
47
+ let scriptPath: string | undefined;
48
+ let shape: Record<string, CapabilityShape> | undefined;
49
+ let started = false;
50
+
51
+ function send(frame: ChildFrame): void {
52
+ socket.write(encodeFrame(frame));
53
+ }
54
+
55
+ /**
56
+ * Write the terminal frame and leave.
57
+ *
58
+ * The explicit exit is deliberate. A hook that leaves a timer or an open
59
+ * handle behind would otherwise hold this process open long past its answer,
60
+ * and the parent — which treats process exit as the one terminal event —
61
+ * would sit there until the timeout killed a hook that had already finished.
62
+ */
63
+ function finish(frame: ChildFrame): void {
64
+ socket.end(encodeFrame(frame), () => process.exit(0));
65
+ // The flush callback does not fire if the peer is already gone.
66
+ setTimeout(() => process.exit(0), 2000).unref();
67
+ }
68
+
69
+ const logger: HookLogger = {
70
+ info: (message) => send({ type: 'log', level: 'info', message }),
71
+ warn: (message) => send({ type: 'log', level: 'warn', message }),
72
+ error: (message) => send({ type: 'log', level: 'error', message }),
73
+ success: (message) => send({ type: 'log', level: 'success', message }),
74
+ };
75
+
76
+ /**
77
+ * Rebuild `context.capabilities` from the shape descriptor.
78
+ *
79
+ * Each method forwards; each non-function property is copied verbatim, which
80
+ * is what keeps `providerModuleId` readable. A method the provider does not
81
+ * implement is simply absent from the descriptor and therefore absent here, so
82
+ * `if (capabilities.firewall.registerTrustedSource)` still answers correctly
83
+ * with no special case for optional methods.
84
+ */
85
+ function buildCapabilities(descriptor: Record<string, CapabilityShape>): Record<string, unknown> {
86
+ const capabilities: Record<string, unknown> = {};
87
+
88
+ for (const [name, entry] of Object.entries(descriptor)) {
89
+ const proxy: Record<string, unknown> = { ...entry.data };
90
+ for (const method of entry.methods) {
91
+ proxy[method] = (...args: unknown[]) => callBroker(name, method, args);
92
+ }
93
+ capabilities[name] = proxy;
94
+ }
95
+
96
+ return capabilities;
97
+ }
98
+
99
+ function callBroker(capability: string, method: string, args: unknown[]): Promise<unknown> {
100
+ const id = `c${nextCallId++}`;
101
+ return new Promise((resolve, reject) => {
102
+ pending.set(id, { resolve, reject });
103
+ send({ type: 'call', id, capability, method, args });
104
+ });
105
+ }
106
+
107
+ /**
108
+ * The `defineHook` brand check, moved here from the executor unchanged
109
+ * (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives
110
+ * the identity boundary between this copy of `@celilo/capabilities` and the
111
+ * one the module bundles (celilo#173) — which is the same reason the check
112
+ * could move at all.
113
+ */
114
+ async function runHook(): Promise<void> {
115
+ if (started || !contextData || !shape || !scriptPath) return;
116
+ started = true;
117
+
118
+ try {
119
+ // celilo built this object and removed exactly two fields, so the cast
120
+ // says something true. The check is here because a shape error would
121
+ // otherwise surface deep inside somebody's hook as a missing property.
122
+ for (const field of ['config', 'secrets', 'systems', 'debug', 'screenshotDir'] as const) {
123
+ if (!(field in contextData)) throw new Error(`Hook context is missing '${field}'.`);
124
+ }
125
+
126
+ const context = {
127
+ ...contextData,
128
+ logger,
129
+ capabilities: buildCapabilities(shape),
130
+ } as unknown as HookContext;
131
+
132
+ const module = await import(scriptPath);
133
+
134
+ if (typeof module.default !== 'function') {
135
+ throw new Error(`Hook script must export a default function: ${scriptPath}`);
136
+ }
137
+
138
+ if (!isCompiledHook(module.default)) {
139
+ throw new Error(
140
+ `Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
141
+ );
142
+ }
143
+
144
+ const result = await module.default(context);
145
+
146
+ if (result === null || result === undefined) {
147
+ finish({ type: 'result', outputs: {} });
148
+ return;
149
+ }
150
+
151
+ if (typeof result !== 'object' || Array.isArray(result)) {
152
+ throw new Error('Hook script must return an object (or nothing)');
153
+ }
154
+
155
+ finish({ type: 'result', outputs: result as Record<string, unknown> });
156
+ } catch (error) {
157
+ finish({ type: 'throw', error: serializeError(error) });
158
+ }
159
+ }
160
+
161
+ const feed = createLineReader((line) => {
162
+ const parsed = parseParentFrame(line);
163
+ if (!parsed.ok) {
164
+ // Deliberately WITHOUT the offending line, unlike the broker's mirror of
165
+ // this check. celilo builds these frames with `JSON.stringify`, so their
166
+ // content is no help in diagnosing a parse failure — and the `context`
167
+ // frame carries the module's secrets, which would then ride an error
168
+ // message out to the operator's terminal and any alert it raises. The
169
+ // other direction echoes the line because there the bytes ARE the
170
+ // diagnostic: a grandchild writing to the socket is the failure design D3
171
+ // exists to catch.
172
+ finish({
173
+ type: 'throw',
174
+ error: {
175
+ name: 'Error',
176
+ message: 'Malformed frame from celilo; the hook context could not be read.',
177
+ },
178
+ });
179
+ return;
180
+ }
181
+
182
+ const frame = parsed.frame;
183
+ switch (frame.type) {
184
+ case 'context': {
185
+ const mismatch = versionMismatch(frame.protocolVersion, 'celilo');
186
+ if (mismatch) {
187
+ finish({ type: 'throw', error: { name: 'Error', message: mismatch } });
188
+ return;
189
+ }
190
+ contextData = frame.context;
191
+ scriptPath = frame.scriptPath;
192
+ void runHook();
193
+ return;
194
+ }
195
+
196
+ case 'capabilities':
197
+ shape = frame.shape;
198
+ void runHook();
199
+ return;
200
+
201
+ case 'return': {
202
+ pending.get(frame.id)?.resolve(frame.value);
203
+ pending.delete(frame.id);
204
+ return;
205
+ }
206
+
207
+ case 'throw': {
208
+ // Rebuilt inline rather than via deserializeError so the hook's own
209
+ // `catch` sees a real Error carrying MissingProviderInputError's fields
210
+ // — `isMissingProviderInputError` is duck-typed and reads them.
211
+ const rebuilt = new Error(frame.error.message);
212
+ rebuilt.name = frame.error.name;
213
+ if (frame.error.stack) rebuilt.stack = frame.error.stack;
214
+ for (const [key, value] of Object.entries(frame.error.fields ?? {})) {
215
+ (rebuilt as unknown as Record<string, unknown>)[key] = value;
216
+ }
217
+ pending.get(frame.id)?.reject(rebuilt);
218
+ pending.delete(frame.id);
219
+ return;
220
+ }
221
+ }
222
+ });
223
+
224
+ socket.on('data', feed);
225
+ socket.on('connect', () => send({ type: 'ready', protocolVersion: HOOK_PROTOCOL_VERSION }));
226
+ socket.on('error', (error) => {
227
+ // The channel is the only way to report anything, so there is nowhere to
228
+ // send this. Exit non-zero and let the parent say what it saw.
229
+ process.stderr.write(`hook runner: socket error: ${error.message}\n`);
230
+ process.exit(3);
231
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * celilo#1003: a hook that times out must be KILLED, not abandoned.
3
+ *
4
+ * `module-lifecycle`'s spec has always required this:
5
+ *
6
+ * > WHEN a hook produces no output for longer than the idle timeout
7
+ * > THEN celilo SHALL terminate it rather than hang indefinitely
8
+ *
9
+ * The old executor raced the hook's promise against a timer and cancelled
10
+ * nothing, because a promise cannot be cancelled. The existing suite asserted
11
+ * the rejection, which held, and the requirement did not.
12
+ *
13
+ * So these tests assert the harm rather than the rejection: a marker file the
14
+ * hook writes only after its bound has passed. Both of them fail against the
15
+ * in-process executor and neither says anything about how the kill is
16
+ * implemented.
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from 'bun:test';
20
+ import { execSync } from 'node:child_process';
21
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { executeHookScript } from './executor';
25
+ import { createCapturingLogger } from './logger';
26
+ import type { HookContext } from './types';
27
+
28
+ const FIXTURES = join(__dirname, 'test-fixtures');
29
+ const dirs: string[] = [];
30
+
31
+ afterEach(() => {
32
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ function scratch(): string {
36
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-'));
37
+ dirs.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ function contextFor(config: Record<string, unknown>): HookContext {
42
+ return {
43
+ config,
44
+ secrets: {},
45
+ systems: [],
46
+ logger: createCapturingLogger().logger,
47
+ debug: false,
48
+ screenshotDir: scratch(),
49
+ capabilities: {},
50
+ };
51
+ }
52
+
53
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
54
+
55
+ /**
56
+ * Hook processes still parented to this one. By ppid rather than by script
57
+ * name: matching the name picks up whatever shell has the filename in its own
58
+ * command line, which is a false positive that looks exactly like a real leak.
59
+ */
60
+ function survivingHookProcesses(): string[] {
61
+ return execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' })
62
+ .split('\n')
63
+ .filter(
64
+ (line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'),
65
+ );
66
+ }
67
+
68
+ describe('hook timeout is a kill, not a race', () => {
69
+ test('a hook that outruns its total timeout stops doing work', async () => {
70
+ const marker = join(scratch(), 'kept-running');
71
+
72
+ await expect(
73
+ executeHookScript(
74
+ join(FIXTURES, 'runaway-hook.ts'),
75
+ contextFor({ sleep_ms: 1500, marker_path: marker }),
76
+ 400,
77
+ 400,
78
+ ),
79
+ ).rejects.toThrow(/timeout/i);
80
+
81
+ // Past when the abandoned hook would have written it.
82
+ await sleep(2000);
83
+ expect(existsSync(marker)).toBe(false);
84
+ expect(survivingHookProcesses()).toEqual([]);
85
+ }, 15_000);
86
+
87
+ test('a hook that declines SIGTERM is killed anyway', async () => {
88
+ const marker = join(scratch(), 'survived');
89
+
90
+ await expect(
91
+ executeHookScript(
92
+ join(FIXTURES, 'sigterm-ignoring-hook.ts'),
93
+ contextFor({ sleep_ms: 6000, marker_path: marker }),
94
+ 400,
95
+ 400,
96
+ ),
97
+ ).rejects.toThrow(/timeout/i);
98
+
99
+ await sleep(6500);
100
+ expect(existsSync(marker)).toBe(false);
101
+ expect(survivingHookProcesses()).toEqual([]);
102
+ }, 20_000);
103
+ });