@celilo/cli 1.8.0 → 1.9.1

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 (66) 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 +153 -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/define-hook.test.ts +1 -0
  17. package/src/hooks/executor.test.ts +7 -0
  18. package/src/hooks/executor.ts +245 -111
  19. package/src/hooks/hook-protocol.test.ts +192 -0
  20. package/src/hooks/hook-protocol.ts +275 -0
  21. package/src/hooks/hook-runner.ts +231 -0
  22. package/src/hooks/hook-state-dir.test.ts +109 -0
  23. package/src/hooks/hook-timeout.test.ts +104 -0
  24. package/src/hooks/hook-trespass.test.ts +202 -0
  25. package/src/hooks/injected-capabilities.test.ts +75 -0
  26. package/src/hooks/mount-set.test.ts +148 -0
  27. package/src/hooks/mount-set.ts +234 -0
  28. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  29. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  30. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  31. package/src/manifest/contracts/v1.ts +21 -6
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/packaging/module-state-directory.test.ts +105 -0
  35. package/src/module/packaging/package-rules.ts +10 -2
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/capability-shape.ts +13 -1
  38. package/src/policy/module-business-baseline.ts +36 -0
  39. package/src/policy/module-dep-reachability.test.ts +167 -0
  40. package/src/services/alerting/ack.test.ts +2 -2
  41. package/src/services/alerting/deferral.test.ts +2 -2
  42. package/src/services/alerting/delivery-loop.test.ts +2 -2
  43. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  44. package/src/services/alerting/inbound-poller.test.ts +2 -2
  45. package/src/services/alerting/inbound.test.ts +2 -2
  46. package/src/services/alerting/notification-responder.test.ts +2 -2
  47. package/src/services/alerting/run-monitor.test.ts +2 -2
  48. package/src/services/alerting/store.test.ts +2 -2
  49. package/src/services/alerting/sweep-runner.test.ts +2 -2
  50. package/src/services/alerting/tokens.test.ts +2 -2
  51. package/src/services/capability-bindings.test.ts +104 -0
  52. package/src/services/capability-bindings.ts +107 -0
  53. package/src/services/capability-table-rows.test.ts +2 -2
  54. package/src/services/consumer-cleanup.test.ts +40 -3
  55. package/src/services/dns-internal-records.test.ts +3 -3
  56. package/src/services/fleet-checks.test.ts +4 -4
  57. package/src/services/module-instances.test.ts +198 -0
  58. package/src/services/module-instances.ts +96 -0
  59. package/src/services/module-journal.test.ts +2 -2
  60. package/src/services/module-subscriptions.test.ts +1 -1
  61. package/src/services/port-forwards.test.ts +2 -2
  62. package/src/services/trusted-sources.test.ts +3 -3
  63. package/src/templates/ingress-ip.test.ts +31 -0
  64. package/src/test-utils/database.ts +31 -1
  65. package/src/test-utils/module-fixtures.ts +147 -35
  66. package/src/test-utils/setup-test-db.ts +0 -80
@@ -7,14 +7,28 @@
7
7
  * - Output validation
8
8
  * - Structured logging
9
9
  *
10
- * **Execution model:** hook scripts run *in-process* on the celilo CLI host.
11
- * `runScript` dynamically `import()`s the hook module and awaits its default
12
- * export. They do NOT execute on the target machine. A hook that needs to
13
- * touch the target initiates SSH outbound itself (see e.g.
14
- * modules/lunacycle/celilo/scripts/health-check.ts's `ssh()` helper). This
15
- * means hook scripts can freely use npm packages, make HTTP calls, read
16
- * local files, etc. — and conversely, anything they depend on (chromium,
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,9 +37,9 @@ 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,
42
+ moduleStateDir,
29
43
  } from '@celilo/capabilities';
30
44
  import {
31
45
  type ContractHookSignature,
@@ -35,6 +49,14 @@ import {
35
49
  } from '../manifest/contracts';
36
50
  import { isPrivilegedCapability } from '../manifest/validate';
37
51
  import { pruneModuleArtifacts } from './artifact-retention';
52
+ import { startBroker } from './broker';
53
+ import {
54
+ HOOK_PROTOCOL_VERSION,
55
+ HOOK_PROTOCOL_VERSION_ENV,
56
+ HOOK_SOCKET_ENV,
57
+ createLineReader,
58
+ deserializeError,
59
+ } from './hook-protocol';
38
60
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
39
61
 
40
62
  /** Default total timeout: 60 seconds */
@@ -51,6 +73,76 @@ const DEFAULT_TIMEOUT_MS = 60_000;
51
73
  */
52
74
  const IDLE_TIMEOUT_MS = 30_000;
53
75
 
76
+ /** How often the idle tracker looks. Unchanged from the promise-based timer. */
77
+ const IDLE_POLL_MS = 5_000;
78
+
79
+ /**
80
+ * SIGTERM is a request. A hook that traps it and keeps working would leave the
81
+ * boundary buying nothing over the promise race it replaced, so SIGKILL
82
+ * follows. Short, because by this point celilo has already told the operator
83
+ * the hook failed.
84
+ */
85
+ const SIGKILL_GRACE_MS = 2_000;
86
+
87
+ /**
88
+ * Environment variables a hook inherits. Everything else is dropped (D5).
89
+ *
90
+ * Two kinds, and the distinction is the whole rule.
91
+ *
92
+ * **How to run** — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`. A hook that spawns
93
+ * anything needs these, and withholding them hides no path.
94
+ *
95
+ * **How to reach the network, and whom to trust** — the CA and proxy families.
96
+ * These configure the RUNTIME, not the module: they say which certificate
97
+ * authorities this host trusts and how it reaches the internet. They name no
98
+ * secret, they are not module-specific, and withholding them reduces a hook's
99
+ * authority by nothing at all. It only makes its TLS connections wrong.
100
+ *
101
+ * `NODE_EXTRA_CA_CERTS` is why this second group exists rather than being an
102
+ * afterthought. `packages/e2e/docker/Dockerfile.management:107` sets it because
103
+ * `bun fetch()` otherwise ignores `/etc/ssl/certs/ca-certificates.crt`
104
+ * entirely and rejects every certificate the simulated CA issues. Twelve
105
+ * hook-reachable module files make HTTPS requests. Dropping it would have
106
+ * broken all of them, in the rig only, as a TLS error naming no cause.
107
+ *
108
+ * The proxy family is not set anywhere today. It is here because it is the
109
+ * same kind of thing, it costs nothing when absent, and the alternative is
110
+ * discovering it the way `NODE_EXTRA_CA_CERTS` was discovered.
111
+ *
112
+ * **`TF_CLI_CONFIG_FILE` is deliberately NOT here.** It is toolchain
113
+ * configuration too, but no hook invokes the terraform binary — every mention
114
+ * of terraform in a module script is a `generated/terraform/` PATH. celilo runs
115
+ * terraform from its own process, which keeps its own environment. Recorded so
116
+ * the next reader does not have to re-derive it.
117
+ *
118
+ * What is NOT in either group: anything module-specific. A module that needs a
119
+ * value declares it (config) or is handed it (a hook input). See the note on
120
+ * `DDNS_ENDPOINT` in openspec/changes/hook-process-boundary/baseline.md for the
121
+ * one case that got this wrong and what was done about it.
122
+ */
123
+ const FORWARDED_ENV = [
124
+ // How to run.
125
+ 'PATH',
126
+ 'HOME',
127
+ 'LANG',
128
+ 'TZ',
129
+ 'TMPDIR',
130
+ // Whom to trust.
131
+ 'NODE_EXTRA_CA_CERTS',
132
+ 'SSL_CERT_FILE',
133
+ 'SSL_CERT_DIR',
134
+ // How to reach the network.
135
+ 'HTTP_PROXY',
136
+ 'HTTPS_PROXY',
137
+ 'NO_PROXY',
138
+ 'http_proxy',
139
+ 'https_proxy',
140
+ 'no_proxy',
141
+ ] as const;
142
+
143
+ /** The shim celilo spawns. Resolved from here so an npm install finds it too. */
144
+ const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
145
+
54
146
  /**
55
147
  * Validate hook inputs against a contract signature.
56
148
  *
@@ -148,127 +240,159 @@ export async function executeHookScript(
148
240
  throw new Error(`Hook script not found: ${scriptPath}`);
149
241
  }
150
242
 
151
- // Create an idle timeout tracker
243
+ // Activity is now ANY frame or ANY byte the child writes, rather than only a
244
+ // `ctx.logger` call. That is strictly more generous than the old tracker and
245
+ // it removes a class of false idle-kill: a hook that prints progress to
246
+ // stdout used to look silent (celilo#622 is the same family).
152
247
  let lastActivity = Date.now();
153
- const originalLogger = context.logger;
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
- },
248
+ const markActive = () => {
249
+ lastActivity = Date.now();
173
250
  };
174
251
 
175
- const trackedContext: HookContext = {
176
- ...context,
177
- logger: trackedLogger,
178
- };
252
+ const { logger, ...serializableContext } = context;
253
+ delete (serializableContext as Record<string, unknown>).capabilities;
179
254
 
180
- // Execute with timeout — disable idle timeout in debug mode
181
- const promises: Promise<Record<string, unknown>>[] = [
182
- runScript(scriptPath, trackedContext),
183
- createTimeoutPromise(timeoutMs, 'total'),
184
- ];
185
- if (!context.debug) {
186
- promises.push(createIdleTimeoutPromise(() => Date.now() - lastActivity, idleTimeoutMs));
187
- }
188
- const result = await Promise.race(promises);
255
+ const broker = await startBroker({
256
+ capabilities: context.capabilities,
257
+ context: serializableContext as Record<string, unknown>,
258
+ scriptPath,
259
+ logger,
260
+ onActivity: markActive,
261
+ });
189
262
 
190
- return result;
191
- }
263
+ try {
264
+ const child = Bun.spawn({
265
+ cmd: [process.execPath, HOOK_RUNNER_PATH],
266
+ env: hookChildEnv(broker.socketPath),
267
+ stdout: 'pipe',
268
+ stderr: 'pipe',
269
+ });
270
+
271
+ // The child's stdout and stderr stay what they are — human output. They
272
+ // now reach `ctx.logger` instead of celilo's own stdout, which is the
273
+ // attribution: a `console.log` from an in-process hook landed unlabelled
274
+ // in the CLI's output with nothing saying which hook wrote it.
275
+ const forwarding = Promise.all([
276
+ forwardStream(child.stdout, logger.info.bind(logger), markActive),
277
+ forwardStream(child.stderr, logger.warn.bind(logger), markActive),
278
+ ]);
279
+
280
+ let killedFor: string | null = null;
281
+ const kill = (reason: string) => {
282
+ if (killedFor) return;
283
+ killedFor = reason;
284
+ // Refuse capability calls FIRST. A hook between `kill` and its own death
285
+ // can still have a call in flight, and answering it is exactly the
286
+ // failure this replaces.
287
+ broker.stop();
288
+ child.kill('SIGTERM');
289
+ setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
290
+ };
192
291
 
193
- /**
194
- * Run a hook script by dynamically importing it.
195
- *
196
- * HOOK_API_V2 Phase 8 (D8): the executor only accepts hook scripts that
197
- * use `defineHook` from `@celilo/capabilities`. The compiled output
198
- * carries a brand symbol (`CELILO_HOOK_BRAND`) that this function
199
- * checks via `isCompiledHook`. Raw `async function(context)` exports are
200
- * rejected with a clear error pointing at the migration guide — no more
201
- * silent failures from forgotten brand registration.
202
- *
203
- * @param scriptPath - Absolute path to script
204
- * @param context - Hook context
205
- * @returns Script outputs
206
- */
207
- async function runScript(
208
- scriptPath: string,
209
- context: HookContext,
210
- ): Promise<Record<string, unknown>> {
211
- const module = await import(scriptPath);
292
+ const totalTimer = setTimeout(() => kill('total'), timeoutMs);
293
+ // Debug runs get no idle timer, same as before: an operator stepping
294
+ // through a browser hook is silent for minutes on purpose.
295
+ const idleTimer = context.debug
296
+ ? undefined
297
+ : setInterval(() => {
298
+ if (Date.now() - lastActivity >= idleTimeoutMs) kill('idle');
299
+ }, IDLE_POLL_MS);
300
+
301
+ let exitCode: number;
302
+ try {
303
+ exitCode = await child.exited;
304
+ // Both channels, not just the process: the terminal frame can still be
305
+ // in the socket buffer when the exit is observed.
306
+ await broker.drained();
307
+ await forwarding;
308
+ } finally {
309
+ clearTimeout(totalTimer);
310
+ if (idleTimer) clearInterval(idleTimer);
311
+ }
212
312
 
213
- if (typeof module.default !== 'function') {
214
- throw new Error(`Hook script must export a default function: ${scriptPath}`);
215
- }
313
+ // Process exit is the ONE terminal event, so there is no race to lose and
314
+ // nothing keeps running past this line. That is the difference from the
315
+ // `Promise.race` this replaces (celilo#1003).
316
+ if (killedFor === 'total') {
317
+ throw new Error(`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)`);
318
+ }
319
+ if (killedFor === 'idle') {
320
+ throw new Error(
321
+ `Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
322
+ );
323
+ }
216
324
 
217
- if (!isCompiledHook(module.default)) {
218
- throw new Error(
219
- `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.`,
220
- );
325
+ const outcome = broker.outcome();
326
+ if (!outcome) {
327
+ const faults = broker.faults();
328
+ throw new Error(
329
+ `Hook process exited with code ${exitCode} without returning a result.${
330
+ faults.length > 0 ? ` ${faults.join('; ')}` : ''
331
+ }`,
332
+ );
333
+ }
334
+
335
+ if (!outcome.ok) throw deserializeError(outcome.error);
336
+ return outcome.outputs;
337
+ } finally {
338
+ broker.close();
221
339
  }
340
+ }
222
341
 
223
- const result = await module.default(context);
342
+ /**
343
+ * The child's environment, built from an allow-list (design D5).
344
+ *
345
+ * NOT `...process.env`. `packages/event-bus/src/dispatcher.ts` spawns handlers
346
+ * that way and it grants a subprocess MORE than the in-process call did — the
347
+ * whole environment plus an explicit database path. Copying that pattern would
348
+ * buy the crash isolation and none of the authority reduction.
349
+ *
350
+ * Measured on the operator's own shell: what a hook inherited before this was
351
+ * 24 variables naming celilo state or carrying a credential, twenty of them
352
+ * third-party API tokens with nothing to do with celilo
353
+ * (`openspec/changes/hook-process-boundary/baseline.md`).
354
+ *
355
+ * This is not the boundary — a hook can still COMPUTE the default master-key
356
+ * path from `$HOME` and read it. It is the cheap half, and it is what stops the
357
+ * dispatcher's mistake repeating here. Stage 2's mount set is what makes the
358
+ * path unreachable.
359
+ *
360
+ * Planning function (Rule 10.4) — pure.
361
+ */
362
+ export function hookChildEnv(socketPath: string): Record<string, string> {
363
+ const env: Record<string, string> = {};
224
364
 
225
- if (result === null || result === undefined) {
226
- return {};
365
+ for (const name of FORWARDED_ENV) {
366
+ const value = process.env[name];
367
+ if (value !== undefined) env[name] = value;
227
368
  }
228
369
 
229
- if (typeof result !== 'object' || Array.isArray(result)) {
230
- throw new Error('Hook script must return an object (or nothing)');
231
- }
370
+ env[HOOK_SOCKET_ENV] = socketPath;
371
+ env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
372
+ // Forwarded when the operator set it, not synthesised from `debug` — a hook
373
+ // reads the flag off `ctx.debug`, which crosses in the context frame.
374
+ if (process.env.CELILO_DEBUG !== undefined) env.CELILO_DEBUG = process.env.CELILO_DEBUG;
232
375
 
233
- return result as Record<string, unknown>;
376
+ return env;
234
377
  }
235
378
 
236
- /**
237
- * Create a promise that rejects after a total timeout
238
- */
239
- function createTimeoutPromise(timeoutMs: number, type: string): Promise<never> {
240
- return new Promise((_, reject) => {
241
- setTimeout(() => {
242
- reject(new Error(`Hook ${type} timeout exceeded (${Math.round(timeoutMs / 1000)}s)`));
243
- }, timeoutMs);
379
+ /** Read a piped stream line by line into the logger. */
380
+ async function forwardStream(
381
+ stream: ReadableStream<Uint8Array>,
382
+ emit: (line: string) => void,
383
+ markActive: () => void,
384
+ ): Promise<void> {
385
+ const decoder = new TextDecoder();
386
+ const feed = createLineReader((line) => {
387
+ markActive();
388
+ emit(line);
244
389
  });
245
- }
246
390
 
247
- /**
248
- * Create a promise that rejects when idle timeout is exceeded
249
- * Polls every 5 seconds to check idle duration
250
- */
251
- function createIdleTimeoutPromise(
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
- });
391
+ for await (const chunk of stream) {
392
+ feed(decoder.decode(chunk, { stream: true }));
393
+ }
394
+ // A final line with no trailing newline would otherwise be dropped.
395
+ feed('\n');
272
396
  }
273
397
 
274
398
  export interface InvokeHookOptions {
@@ -505,6 +629,10 @@ export async function invokeHook(
505
629
  // below — the capability pre-flight is one — and nothing ever cleans
506
630
  // those up, so a module accrues one per affected run forever.
507
631
  const screenshotDir = moduleArtifactDir(modulePath, `${hookName}-${startTime}`);
632
+ // Unlike the artifact directory, this one is NOT per-run and is created
633
+ // unconditionally: a hook must be able to write state on its first ever run,
634
+ // and should never have to mkdir its own sanctioned location (celilo#1000).
635
+ const stateDir = moduleStateDir(modulePath);
508
636
 
509
637
  // Build context
510
638
  const loadedCapabilities = options.capabilities ?? {};
@@ -516,6 +644,7 @@ export async function invokeHook(
516
644
  logger,
517
645
  debug,
518
646
  screenshotDir,
647
+ stateDir,
519
648
  capabilities: loadedCapabilities,
520
649
  };
521
650
 
@@ -550,6 +679,11 @@ export async function invokeHook(
550
679
  // Create the artifact directory only once every early return is behind
551
680
  // us, so the `finally` below is guaranteed to run and reclaim it.
552
681
  mkdirSync(screenshotDir, { recursive: true });
682
+ // Created every run and reclaimed on none. An empty `state/` is not litter
683
+ // the way an empty per-run artifact directory is: there is one of them, it is
684
+ // where the module is told to write, and deleting it between runs would make
685
+ // its existence depend on whether the last hook happened to use it.
686
+ mkdirSync(stateDir, { recursive: true });
553
687
  // Prune on write, so retention needs no scheduler of its own and cannot
554
688
  // fall behind a module that runs often.
555
689
  pruneModuleArtifacts(dirname(screenshotDir));
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { MissingProviderInputError, isMissingProviderInputError } from '@celilo/capabilities';
3
+ import {
4
+ type ChildFrame,
5
+ HOOK_PROTOCOL_VERSION,
6
+ type ParentFrame,
7
+ createLineReader,
8
+ deserializeError,
9
+ encodeFrame,
10
+ parseChildFrame,
11
+ parseParentFrame,
12
+ serializeError,
13
+ versionMismatch,
14
+ } from './hook-protocol';
15
+
16
+ const CHILD_FRAMES: ChildFrame[] = [
17
+ { type: 'ready', protocolVersion: HOOK_PROTOCOL_VERSION },
18
+ {
19
+ type: 'call',
20
+ id: 'c1',
21
+ capability: 'public_web',
22
+ method: 'register_route',
23
+ args: [{ path: '/x' }],
24
+ },
25
+ { type: 'log', level: 'info', message: 'hello' },
26
+ { type: 'log', level: 'success', message: 'done' },
27
+ { type: 'result', outputs: { api_key: 'k' } },
28
+ { type: 'throw', error: { name: 'Error', message: 'boom', stack: 'at x' } },
29
+ ];
30
+
31
+ const PARENT_FRAMES: ParentFrame[] = [
32
+ {
33
+ type: 'context',
34
+ protocolVersion: HOOK_PROTOCOL_VERSION,
35
+ scriptPath: '/m/scripts/h.ts',
36
+ context: { config: { a: 1 }, secrets: {}, systems: [], debug: false, screenshotDir: '/tmp/a' },
37
+ },
38
+ {
39
+ type: 'capabilities',
40
+ shape: { firewall: { methods: ['exposeService'], data: { providerModuleId: 'iptables' } } },
41
+ },
42
+ { type: 'return', id: 'c1', value: { success: true } },
43
+ { type: 'throw', id: 'c1', error: { name: 'Error', message: 'nope' } },
44
+ ];
45
+
46
+ describe('hook protocol', () => {
47
+ describe('round trip', () => {
48
+ for (const frame of CHILD_FRAMES) {
49
+ test(`child ${frame.type}${'level' in frame ? `/${frame.level}` : ''}`, () => {
50
+ const parsed = parseChildFrame(encodeFrame(frame).trimEnd());
51
+ expect(parsed.ok).toBe(true);
52
+ if (parsed.ok) expect(parsed.frame).toEqual(frame);
53
+ });
54
+ }
55
+
56
+ for (const frame of PARENT_FRAMES) {
57
+ test(`parent ${frame.type}`, () => {
58
+ const parsed = parseParentFrame(encodeFrame(frame).trimEnd());
59
+ expect(parsed.ok).toBe(true);
60
+ if (parsed.ok) expect(parsed.frame).toEqual(frame);
61
+ });
62
+ }
63
+
64
+ test('every frame ends in exactly one newline', () => {
65
+ for (const frame of [...CHILD_FRAMES, ...PARENT_FRAMES]) {
66
+ const encoded = encodeFrame(frame);
67
+ expect(encoded.endsWith('\n')).toBe(true);
68
+ expect(encoded.slice(0, -1)).not.toContain('\n');
69
+ }
70
+ });
71
+ });
72
+
73
+ describe('malformed input is a value, never a throw', () => {
74
+ // The boundary's whole claim is that the child cannot take celilo down. A
75
+ // reader that throws on a bad line hands that back.
76
+ const bad = [
77
+ '',
78
+ 'not json at all',
79
+ '{',
80
+ '{"type":"nope"}',
81
+ '{"type":"call"}',
82
+ 'null',
83
+ '[]',
84
+ '"a string"',
85
+ ];
86
+
87
+ for (const line of bad) {
88
+ test(`child reader survives ${JSON.stringify(line)}`, () => {
89
+ const parsed = parseChildFrame(line);
90
+ expect(parsed.ok).toBe(false);
91
+ if (!parsed.ok) expect(parsed.error.length).toBeGreaterThan(0);
92
+ });
93
+ }
94
+
95
+ test('a very long malformed line is truncated in the message', () => {
96
+ const parsed = parseChildFrame('x'.repeat(5000));
97
+ expect(parsed.ok).toBe(false);
98
+ if (!parsed.ok) expect(parsed.error.length).toBeLessThan(200);
99
+ });
100
+
101
+ test('a parent frame is not a child frame', () => {
102
+ expect(parseChildFrame(encodeFrame(PARENT_FRAMES[1]).trimEnd()).ok).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('line reader', () => {
107
+ test('reassembles a frame split across chunks', () => {
108
+ const lines: string[] = [];
109
+ const feed = createLineReader((l) => lines.push(l));
110
+ const encoded = encodeFrame(CHILD_FRAMES[1]);
111
+ feed(encoded.slice(0, 7));
112
+ expect(lines).toEqual([]);
113
+ feed(encoded.slice(7));
114
+ expect(lines).toHaveLength(1);
115
+ expect(parseChildFrame(lines[0]).ok).toBe(true);
116
+ });
117
+
118
+ test('splits several frames arriving in one chunk', () => {
119
+ const lines: string[] = [];
120
+ const feed = createLineReader((l) => lines.push(l));
121
+ feed(CHILD_FRAMES.map(encodeFrame).join(''));
122
+ expect(lines).toHaveLength(CHILD_FRAMES.length);
123
+ });
124
+
125
+ test('holds a partial tail rather than emitting it', () => {
126
+ const lines: string[] = [];
127
+ const feed = createLineReader((l) => lines.push(l));
128
+ feed(`${encodeFrame(CHILD_FRAMES[0])}{"type":"log"`);
129
+ expect(lines).toHaveLength(1);
130
+ });
131
+ });
132
+
133
+ describe('handshake', () => {
134
+ test('matching versions pass', () => {
135
+ expect(versionMismatch(HOOK_PROTOCOL_VERSION, 'the hook runner')).toBeNull();
136
+ });
137
+
138
+ test('a mismatch names both numbers', () => {
139
+ const message = versionMismatch(99, 'the hook runner');
140
+ expect(message).toContain('99');
141
+ expect(message).toContain(String(HOOK_PROTOCOL_VERSION));
142
+ expect(message).toContain('the hook runner');
143
+ });
144
+ });
145
+
146
+ describe('errors', () => {
147
+ test('a plain Error keeps name, message and stack', () => {
148
+ const rebuilt = deserializeError(serializeError(new TypeError('bad shape')));
149
+ expect(rebuilt.name).toBe('TypeError');
150
+ expect(rebuilt.message).toBe('bad shape');
151
+ expect(rebuilt.stack).toBeTruthy();
152
+ });
153
+
154
+ test('a non-Error throw still crosses', () => {
155
+ expect(deserializeError(serializeError('just a string')).message).toBe('just a string');
156
+ });
157
+
158
+ test('MissingProviderInputError survives, fields intact', () => {
159
+ // The one error the framework READS rather than displays. If the four
160
+ // fields do not survive, the cross-module ensure interview never runs
161
+ // and the deploy fails with a message instead of a question.
162
+ const original = new MissingProviderInputError({
163
+ providerModuleId: 'caddy',
164
+ ensureId: 'hostnames',
165
+ value: 'foo.example.com',
166
+ humanContext: 'so the route resolves',
167
+ });
168
+
169
+ const rebuilt = deserializeError(serializeError(original));
170
+
171
+ expect(isMissingProviderInputError(rebuilt)).toBe(true);
172
+ if (!isMissingProviderInputError(rebuilt)) throw new Error('unreachable');
173
+ expect(rebuilt.providerModuleId).toBe('caddy');
174
+ expect(rebuilt.ensureId).toBe('hostnames');
175
+ expect(rebuilt.value).toBe('foo.example.com');
176
+ expect(rebuilt.humanContext).toBe('so the route resolves');
177
+ });
178
+
179
+ test('an absent humanContext does not become the string "undefined"', () => {
180
+ const rebuilt = deserializeError(
181
+ serializeError(
182
+ new MissingProviderInputError({ providerModuleId: 'p', ensureId: 'e', value: 'v' }),
183
+ ),
184
+ );
185
+ expect((rebuilt as unknown as Record<string, unknown>).humanContext).toBeUndefined();
186
+ });
187
+
188
+ test('an ordinary Error carries no fields envelope', () => {
189
+ expect(serializeError(new Error('x')).fields).toBeUndefined();
190
+ });
191
+ });
192
+ });