@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.
Files changed (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Recurrence gate for celilo#1074.
3
+ *
4
+ * `db/client.ts` runs `PRAGMA foreign_keys = ON` before handing back the real
5
+ * database. The test helpers did not, and SQLite defaults the pragma OFF per
6
+ * connection, so every one of the schema's `onDelete: 'cascade'` declarations
7
+ * was enforced in production and inert in the suite.
8
+ *
9
+ * The direction is what made it invisible. An unenforced cascade can only ever
10
+ * make a test pass that should have failed, so nothing has ever gone red over
11
+ * it and nothing ever could. `consumer-cleanup.ts`'s whole design reasons about
12
+ * what the cascade removes and when, and no test could observe any of it.
13
+ *
14
+ * Two assertions, because the pragma buys two different things: rows go away
15
+ * when their parent does, and a row referencing an absent parent is refused.
16
+ * The second is what stops a structurally impossible fixture from reading as
17
+ * valid.
18
+ */
19
+
20
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { mkdtemp, rm } from 'node:fs/promises';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import {
25
+ cleanupTestDatabase,
26
+ setupTestDatabase,
27
+ setupTestDatabaseAt,
28
+ setupTestDatabaseFile,
29
+ } from '../test-utils/database';
30
+ import type { DbClient } from './client';
31
+
32
+ describe('test databases enforce foreign keys', () => {
33
+ let db: DbClient;
34
+
35
+ beforeEach(async () => {
36
+ db = await setupTestDatabase();
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await cleanupTestDatabase(db);
41
+ });
42
+
43
+ test('deleting a module takes its dependent rows with it', () => {
44
+ db.$client.run(
45
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy-consumer', 'consumer', '1.0.0', '/tmp/c', '{}')`,
46
+ );
47
+ db.$client.run(
48
+ `INSERT INTO web_routes (slug, module_id, type, path, hostname) VALUES ('s', 'caddy-consumer', 'reverse_proxy', '/', 'example.com')`,
49
+ );
50
+
51
+ db.$client.run(`DELETE FROM modules WHERE id = 'caddy-consumer'`);
52
+
53
+ const rows = db.$client
54
+ .query(`SELECT id FROM web_routes WHERE module_id = 'caddy-consumer'`)
55
+ .all();
56
+ expect(rows).toHaveLength(0);
57
+ });
58
+
59
+ test('a row referencing a module that does not exist is refused', () => {
60
+ expect(() =>
61
+ db.$client.run(
62
+ `INSERT INTO capabilities (module_id, capability_name, version, data) VALUES ('nonexistent', 'public_web', '1.0.0', '{}')`,
63
+ ),
64
+ ).toThrow();
65
+ });
66
+ });
67
+
68
+ /**
69
+ * Every way to get a test database, checked directly.
70
+ *
71
+ * `test-utils/database.ts` is now the only module that hands one out — the
72
+ * second one collided with it on two exported names and disagreed with it on
73
+ * this pragma, which is why the gap was invisible from any call site. A helper
74
+ * added here later must be added to this list; there is no longer another module
75
+ * for it to hide in.
76
+ */
77
+ describe('every test-database helper enforces foreign keys', () => {
78
+ const enforced = (client: DbClient): boolean =>
79
+ (client.$client.query('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys ===
80
+ 1;
81
+
82
+ test('setupTestDatabase (in memory)', async () => {
83
+ const memory = await setupTestDatabase();
84
+ expect(enforced(memory)).toBe(true);
85
+ await cleanupTestDatabase(memory);
86
+ });
87
+
88
+ test('setupTestDatabaseFile (temp directory it owns)', async () => {
89
+ const { db: file, cleanup } = await setupTestDatabaseFile();
90
+ expect(enforced(file)).toBe(true);
91
+ await cleanup();
92
+ });
93
+
94
+ test('setupTestDatabaseAt (path the caller chooses)', async () => {
95
+ const dir = await mkdtemp(join(tmpdir(), 'celilo-fk-'));
96
+ const at = await setupTestDatabaseAt(join(dir, 'celilo.db'));
97
+ expect(enforced(at)).toBe(true);
98
+ at.$client.close();
99
+ await rm(dir, { recursive: true, force: true });
100
+ });
101
+ });
package/src/db/schema.ts CHANGED
@@ -153,6 +153,54 @@ export const capabilities = sqliteTable('capabilities', {
153
153
  .default(sql`(unixepoch())`),
154
154
  });
155
155
 
156
+ /**
157
+ * Capability bindings — which provider a consumer actually resolved to.
158
+ *
159
+ * `capabilities` is provider-side only: it answers "who COULD provide this",
160
+ * never "who does this module actually use". Resolution happened at hook time
161
+ * and vanished into the generated project. Every consumer of that fact
162
+ * reconstructed the same approximation — the consumer's `requires` + `optional`
163
+ * crossed with `capabilities` — which names every provider a module MIGHT have
164
+ * bound to. For a module declaring four optional capabilities and using one,
165
+ * that is three false edges and no way to tell which.
166
+ *
167
+ * A row is written when a consumer's hook first CALLS a method on an injected
168
+ * capability, not when the loader resolves one. The loader deliberately injects
169
+ * every registered capability regardless of what the consumer declared
170
+ * (`loadCapabilityFunctions`, "not just required ones"), so resolution is a
171
+ * superset of the declared set and recording it would restate the
172
+ * approximation. The call is the binding.
173
+ *
174
+ * Unique on (consumer, capability): a redeploy re-asserts the row rather than
175
+ * duplicating it, and a provider swap rewrites `provider_module_id` in place.
176
+ * Cascaded on the consumer: the binding dies with the module that made it.
177
+ *
178
+ * This does NOT replace the permissive set. `planConsumerCleanup` must still
179
+ * notify every provider that might hold minted state, including ones this table
180
+ * has no row for.
181
+ *
182
+ * @owner celilo — cross-module bookkeeping of who resolved to whom (T8, beside `capabilities`)
183
+ */
184
+ export const capabilityBindings = sqliteTable(
185
+ 'capability_bindings',
186
+ {
187
+ id: integer('id').primaryKey({ autoIncrement: true }),
188
+ consumerModuleId: text('consumer_module_id')
189
+ .notNull()
190
+ .references(() => modules.id, { onDelete: 'cascade' }),
191
+ capabilityName: text('capability_name').notNull(),
192
+ providerModuleId: text('provider_module_id').notNull(),
193
+ /** Last time the consumer called into this provider. */
194
+ boundAt: integer('bound_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
195
+ },
196
+ (table) => ({
197
+ consumerCapabilityUnique: uniqueIndex('capability_bindings_consumer_capability_idx').on(
198
+ table.consumerModuleId,
199
+ table.capabilityName,
200
+ ),
201
+ }),
202
+ );
203
+
156
204
  /**
157
205
  * Capability secrets table - stores encrypted secrets owned by capabilities
158
206
  * Values are encrypted with AES-256-GCM using master key
@@ -251,6 +299,100 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
251
299
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
252
300
  });
253
301
 
302
+ /**
303
+ * Lifecycle states an instance moves through, reported by the deploy worker's
304
+ * `list` so a parent can compare desired against observed (design D6).
305
+ *
306
+ * `failed` and `pending` must stay distinguishable: a parent that cannot tell
307
+ * "has not started" from "will never work" either retries a bug forever or
308
+ * gives up on work that was merely queued.
309
+ */
310
+ export const INSTANCE_STATES = [
311
+ 'pending',
312
+ 'provisioning',
313
+ 'ready',
314
+ 'failed',
315
+ 'destroying',
316
+ ] as const;
317
+
318
+ export type InstanceState = (typeof INSTANCE_STATES)[number];
319
+
320
+ /**
321
+ * Instances of a submodule (openspec/changes/submodules, D2 and D4).
322
+ *
323
+ * An instance IS a row in `modules`, under a celilo-derived id, which is what
324
+ * lets every table and reader keyed on `moduleId` keep working unchanged:
325
+ * `module_systems`, `module_configs`, `module_infrastructure`, health, backup,
326
+ * fleet status and the removal path all needed no change. `moduleId` here is
327
+ * both this table's key and the FK to that row, so an instance cannot exist
328
+ * without the module row it names.
329
+ *
330
+ * This table carries only what a module row cannot express: who owns it, which
331
+ * submodule it came from, and the opaque key its parent knows it by.
332
+ *
333
+ * IDENTITY IS THE TRIPLE (parent, submodule, instanceKey), not `instanceKey`
334
+ * alone (D2). Two parents may use the same key string, and one parent may use
335
+ * the same key across two of its submodules; both are legal and neither
336
+ * collides. `instanceKey` is opaque — celilo stores it and never interprets
337
+ * it, because a key derived from a display name orphans a running system the
338
+ * first time somebody is renamed at the identity provider.
339
+ * @owner celilo — instance ownership and lifecycle state
340
+ */
341
+ export const moduleInstances = sqliteTable(
342
+ 'module_instances',
343
+ {
344
+ /**
345
+ * The derived `modules.id` this instance runs as, e.g. `byoi-lab-sdf82c1e`.
346
+ * Both this table's key and the FK, so the two cannot drift apart.
347
+ */
348
+ moduleId: text('module_id')
349
+ .primaryKey()
350
+ .references(() => modules.id, { onDelete: 'cascade' }),
351
+ /** The module that declared the submodule and created this instance. */
352
+ parentId: text('parent_id')
353
+ .notNull()
354
+ .references(() => modules.id, { onDelete: 'cascade' }),
355
+ /** The submodule name the parent declared — its directory under `submodules/`. */
356
+ submodule: text('submodule').notNull(),
357
+ /** Opaque, stable id supplied by the parent. Never interpreted. */
358
+ instanceKey: text('instance_key').notNull(),
359
+ /**
360
+ * Non-authoritative label for operator display. NOTHING may key on it, and
361
+ * changing it does not change identity — that is the whole point of D2.
362
+ */
363
+ label: text('label'),
364
+ state: text('state').$type<InstanceState>().notNull().default('pending'),
365
+ /**
366
+ * Why a `failed` instance failed. Null unless `state = 'failed'` — the
367
+ * legitimate "this section is absent" case rather than a missing default.
368
+ */
369
+ failureReason: text('failure_reason'),
370
+ /**
371
+ * Whether retrying could succeed. Set with `failureReason`.
372
+ *
373
+ * A parent's reconcile loop reads this to decide whether to rebuild. A
374
+ * missing interview answer (D7) is deterministic and will fail identically
375
+ * forever, while a timed-out provision is worth another go. Without the
376
+ * flag a caller has to string-match `failureReason`, which is the class of
377
+ * bug where a reporter is trusted by its shape and quietly answers wrong.
378
+ */
379
+ retryable: integer('retryable', { mode: 'boolean' }),
380
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
381
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
382
+ },
383
+ (table) => ({
384
+ // The real identity (D2). Makes "this parent already has an instance under
385
+ // this key" a database fact rather than a check somebody has to remember.
386
+ identity: unique().on(table.parentId, table.submodule, table.instanceKey),
387
+ // Every reconcile call lists one parent's instances, so that must not be a
388
+ // table scan once a fleet carries tens of them.
389
+ parentIdx: index('module_instances_parent_idx').on(table.parentId),
390
+ // `list` filters by state, and the collapsed operator view rolls up health
391
+ // per parent, which reads the same way.
392
+ stateIdx: index('module_instances_state_idx').on(table.state),
393
+ }),
394
+ );
395
+
254
396
  /**
255
397
  * IPAM (IP Address Management) allocations table
256
398
  * Tracks VMID and IP address assignments per module
@@ -327,6 +469,12 @@ export const moduleBuilds = sqliteTable('module_builds', {
327
469
  * something declares `network.<zone>.subnet` — normally the module that supplies
328
470
  * the network. Defining a zone here costs nothing and activates nothing.
329
471
  *
472
+ * - isp-transit: the private segment between a celilo firewall and the router
473
+ * upstream of it. Non-placement and non-allocatable, because the upstream
474
+ * router is the address authority. It exists so a deployment CAN separate the
475
+ * wire to that router from the workload network, not so every deployment
476
+ * must: a firewall whose egress leg IS the workload network declares
477
+ * `default_route_zone: internal` and behaves exactly as it does today.
330
478
  * - internal: the semi-trusted LAN the management server sits on
331
479
  * - dmz: public-facing services
332
480
  * - app: internal application services
@@ -345,6 +493,7 @@ export const moduleBuilds = sqliteTable('module_builds', {
345
493
  * holds a leg on and must translate for.
346
494
  */
347
495
  export const NETWORK_ZONES = [
496
+ 'isp-transit',
348
497
  'internal',
349
498
  'dmz',
350
499
  'app',
@@ -366,8 +515,9 @@ export type NetworkZone = (typeof NETWORK_ZONES)[number];
366
515
  * Zones an IP allocation or reservation can name: every NetworkZone whose
367
516
  * addresses celilo hands out.
368
517
  *
369
- * Two are excluded, for the same underlying reason — somebody else is the
518
+ * Three are excluded, for the same underlying reason — somebody else is the
370
519
  * address authority:
520
+ * - `isp-transit`, whose addresses the upstream router assigns;
371
521
  * - `external`, whose systems are addressed by the cloud/VPS provider;
372
522
  * - `vpn`, whose client addresses are assigned by the module terminating the
373
523
  * tunnel. celilo allocating into that subnet would collide with the VPN
@@ -378,17 +528,21 @@ export type NetworkZone = (typeof NETWORK_ZONES)[number];
378
528
  * cast in machine-pool.ts, and the cast had already drifted (it was missing
379
529
  * `secure-mgmt`, and its comment claimed the only difference was `external`).
380
530
  */
381
- export type AllocatableZone = Exclude<NetworkZone, 'external' | 'control-plane-vpn'>;
531
+ export type AllocatableZone = Exclude<
532
+ NetworkZone,
533
+ 'isp-transit' | 'external' | 'control-plane-vpn'
534
+ >;
382
535
 
383
536
  /** The zones whose addresses celilo hands out, as a runtime list. */
384
537
  export const ALLOCATABLE_ZONES: AllocatableZone[] = NETWORK_ZONES.filter(
385
- (zone): zone is AllocatableZone => zone !== 'external' && zone !== 'control-plane-vpn',
538
+ (zone): zone is AllocatableZone =>
539
+ zone !== 'isp-transit' && zone !== 'external' && zone !== 'control-plane-vpn',
386
540
  );
387
541
 
388
542
  /**
389
543
  * Is this a zone celilo allocates addresses in? Use this rather than testing
390
- * `zone !== 'external'` by hand — that check predates `vpn` and read as "the one
391
- * externally-addressed zone" when there are now two.
544
+ * `zone !== 'external'` by hand — that check predates the other externally
545
+ * addressed roles and silently makes them allocatable.
392
546
  */
393
547
  export function isAllocatableZone(zone: NetworkZone): zone is AllocatableZone {
394
548
  return (ALLOCATABLE_ZONES as string[]).includes(zone);
@@ -801,16 +955,33 @@ export const publicDnsEvidence = sqliteTable('public_dns_evidence', {
801
955
  *
802
956
  * `celilo system doctor` reads this to assert service hostnames resolve to
803
957
  * the firewall natIp (LAN-reachable) and not a zone-side container IP that
804
- * a LAN device can't route to. Rows die with either module via FK cascade.
958
+ * a LAN device can't route to. Rows die with their CONSUMER via FK cascade, and
959
+ * NOT with their provider (celilo#1010 — see `providerModuleId`).
805
960
  * @owner capability:dns_internal — resolver configuration; migrates to the provider (T6)
806
961
  */
807
962
  export const dnsInternalRecords = sqliteTable(
808
963
  'dns_internal_records',
809
964
  {
810
965
  id: integer('id').primaryKey({ autoIncrement: true }),
811
- providerModuleId: text('provider_module_id')
812
- .notNull()
813
- .references(() => modules.id, { onDelete: 'cascade' }),
966
+ /**
967
+ * The resolver serving this record. A PLAIN column with no foreign key, and
968
+ * that is the celilo#1010 correction rather than an oversight.
969
+ *
970
+ * It used to cascade, so swapping `technitium` for `knot-unbound-internal`
971
+ * deleted the fleet's entire internal DNS ledger, `zone_routable_ip` view
972
+ * overrides included. `web_routes` cascades on its consumer only and the two
973
+ * docblocks claimed to be siblings, so the divergence read as intent and was
974
+ * not. The claim on a capability-owned table is the CONSUMER
975
+ * (openspec/changes/capability-owned-tables D3/D8), and `dns_internal`'s
976
+ * declaration cannot express anything else.
977
+ *
978
+ * Attribution is still real and still enforced — it is half
979
+ * `dns_internal_records_provider_host_idx` — it just no longer decides when a
980
+ * LIVE record is forgotten. A provider leaving now leaves the ledger for the
981
+ * next one to reconcile from, which is what stage 1's provider-arrival
982
+ * backfill assumes. Migration `0027`.
983
+ */
984
+ providerModuleId: text('provider_module_id').notNull(),
814
985
  consumerModuleId: text('consumer_module_id')
815
986
  .notNull()
816
987
  .references(() => modules.id, { onDelete: 'cascade' }),
@@ -1392,3 +1563,5 @@ export type Alert = typeof alerts.$inferSelect;
1392
1563
  export type NewAlert = typeof alerts.$inferInsert;
1393
1564
  export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
1394
1565
  export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;
1566
+ export type CapabilityBinding = typeof capabilityBindings.$inferSelect;
1567
+ export type NewCapabilityBinding = typeof capabilityBindings.$inferInsert;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The broker: the capability surface crossing a process boundary.
3
+ *
4
+ * The claim under test is design D2's — that one generic proxy covers all
5
+ * twelve capabilities because every hook-facing method is already
6
+ * `(request: JSON) => Promise<JSON>`. So these tests fix the SHAPES a call can
7
+ * take (returns, throws, a structured throw the framework reads, an absent
8
+ * optional method, an unknown method) and say nothing about any particular
9
+ * capability. A per-method suite would prove the same thing thirty-seven times
10
+ * and go stale the moment a provider gained a method.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+ import { execSync } from 'node:child_process';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { MissingProviderInputError } from '@celilo/capabilities';
19
+ import { capabilityShape } from './broker';
20
+ import { executeHookScript } from './executor';
21
+ import { createCapturingLogger } from './logger';
22
+ import type { HookContext } from './types';
23
+
24
+ const FIXTURES = join(__dirname, 'test-fixtures');
25
+
26
+ function demoCapabilities(): Record<string, unknown> {
27
+ return {
28
+ demo: {
29
+ providerModuleId: 'demo-provider',
30
+ version: '1.0.0',
31
+ echo: async (request: unknown) => ({ echoed: request }),
32
+ returnsNothing: async () => undefined,
33
+ boom: async () => {
34
+ throw new Error('plain failure');
35
+ },
36
+ missingInput: async () => {
37
+ throw new MissingProviderInputError({
38
+ providerModuleId: 'caddy',
39
+ ensureId: 'hostnames',
40
+ value: 'foo.example.com',
41
+ humanContext: 'so the route resolves',
42
+ });
43
+ },
44
+ },
45
+ };
46
+ }
47
+
48
+ async function runCapabilityHook(): Promise<Record<string, unknown>> {
49
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-'));
50
+ try {
51
+ const context: HookContext = {
52
+ config: {},
53
+ secrets: {},
54
+ systems: [],
55
+ logger: createCapturingLogger().logger,
56
+ debug: false,
57
+ screenshotDir: dir,
58
+ capabilities: demoCapabilities(),
59
+ };
60
+ return await executeHookScript(
61
+ join(FIXTURES, 'capability-calling-hook.ts'),
62
+ context,
63
+ 30_000,
64
+ 30_000,
65
+ );
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ describe('capabilityShape', () => {
72
+ test('splits functions from data', () => {
73
+ const shape = capabilityShape(demoCapabilities());
74
+ expect(shape.demo.methods.sort()).toEqual(['boom', 'echo', 'missingInput', 'returnsNothing']);
75
+ expect(shape.demo.data).toEqual({ providerModuleId: 'demo-provider', version: '1.0.0' });
76
+ });
77
+
78
+ test('an unimplemented optional method is simply absent', () => {
79
+ // Not "present and throwing". `if (cap.registerTrustedSource)` is real
80
+ // code in the wireguard module and it has to keep answering correctly.
81
+ expect(capabilityShape(demoCapabilities()).demo.methods).not.toContain('sometimesAbsent');
82
+ });
83
+
84
+ test('symbol keys are dropped — they cannot cross JSON', () => {
85
+ const brand = Symbol('brand');
86
+ const shape = capabilityShape({ demo: { [brand]: 'x', ok: async () => 1 } });
87
+ expect(shape.demo.methods).toEqual(['ok']);
88
+ expect(shape.demo.data).toEqual({});
89
+ });
90
+
91
+ test('a value JSON cannot carry is left out of data rather than corrupted', () => {
92
+ const shape = capabilityShape({
93
+ demo: { nan: Number.NaN, inf: Number.POSITIVE_INFINITY, n: 1 },
94
+ });
95
+ expect(shape.demo.data).toEqual({ n: 1 });
96
+ });
97
+
98
+ test('a non-object capability entry is skipped, not crashed on', () => {
99
+ expect(capabilityShape({ broken: null, alsoBroken: 'string' })).toEqual({});
100
+ });
101
+ });
102
+
103
+ describe('capability calls across the boundary', () => {
104
+ test('every call shape survives the round trip', async () => {
105
+ const outputs = await runCapabilityHook();
106
+
107
+ expect(outputs.providerModuleId).toBe('demo-provider');
108
+ expect(outputs.version).toBe('1.0.0');
109
+ expect(outputs.optionalMethodAbsent).toBe(true);
110
+ expect(outputs.returned).toEqual({ echoed: { x: 1, nested: { y: [2, 3] } } });
111
+ expect(outputs.undefinedBecomesNull).toBeNull();
112
+
113
+ expect(outputs.plainThrow).toEqual({
114
+ isError: true,
115
+ name: 'Error',
116
+ message: 'plain failure',
117
+ hasStack: true,
118
+ });
119
+
120
+ // The one error the framework READS rather than displays. Lose these four
121
+ // fields and the cross-module ensure interview never runs — the deploy
122
+ // fails with a message where it should have asked a question.
123
+ expect(outputs.missingProviderInput).toEqual({
124
+ recognised: true,
125
+ providerModuleId: 'caddy',
126
+ ensureId: 'hostnames',
127
+ value: 'foo.example.com',
128
+ humanContext: 'so the route resolves',
129
+ });
130
+
131
+ // A method that is not in the shape is not on the proxy, so this fails on
132
+ // this side and never reaches the broker — the same TypeError a hook gets
133
+ // in-process today. The broker's own "no such method" guard is for a
134
+ // shape/map disagreement, which is a skew bug and not this path.
135
+ expect(outputs.unknownMethod).toContain('not a function');
136
+ }, 30_000);
137
+
138
+ test('the hook ran in a process of its own and left none behind', async () => {
139
+ await runCapabilityHook();
140
+
141
+ // By parent pid, not by name: the hook runner is a direct child of this
142
+ // process, and matching on the script name instead picks up whatever shell
143
+ // happens to have the filename in its own command line.
144
+ const orphans = execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' })
145
+ .split('\n')
146
+ .filter(
147
+ (line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'),
148
+ );
149
+
150
+ expect(orphans).toEqual([]);
151
+ }, 30_000);
152
+ });