@celilo/cli 4.1.0 → 5.0.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 (81) hide show
  1. package/AGENTS.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +9 -0
  4. package/drizzle/0034_private_routes.sql +41 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +2 -2
  7. package/src/api/sessions.test.ts +1 -1
  8. package/src/capabilities/registration.test.ts +11 -11
  9. package/src/capabilities/secrets.test.ts +18 -18
  10. package/src/capabilities/validation.test.ts +24 -24
  11. package/src/cli/cli.test.ts +3 -3
  12. package/src/cli/commands/module-changeset.test.ts +1 -1
  13. package/src/cli/commands/module-generate.test.ts +2 -2
  14. package/src/cli/commands/module-import-aspect.test.ts +2 -2
  15. package/src/cli/commands/module-update.test.ts +14 -14
  16. package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
  17. package/src/cli/commands/module-upgrade.test.ts +3 -3
  18. package/src/cli/commands/module-upgrade.ts +12 -0
  19. package/src/cli/commands/module-version.test.ts +1 -1
  20. package/src/cli/index.ts +22 -2
  21. package/src/cli/restore-migration-failure.test.ts +1 -1
  22. package/src/cli/stdout-pipe-flush.test.ts +35 -0
  23. package/src/db/private-routes.test.ts +159 -0
  24. package/src/db/schema.ts +49 -0
  25. package/src/hooks/capability-loader.ts +179 -12
  26. package/src/hooks/executor.test.ts +13 -13
  27. package/src/hooks/hook-state-dir.test.ts +2 -2
  28. package/src/hooks/mount-set.test.ts +28 -0
  29. package/src/hooks/mount-set.ts +33 -0
  30. package/src/hooks/test-fixtures/store-backed.ts +5 -3
  31. package/src/manifest/contracts/index.ts +28 -7
  32. package/src/manifest/contracts/v2.ts +32 -0
  33. package/src/manifest/icon-schema.test.ts +1 -1
  34. package/src/manifest/schema.ts +27 -2
  35. package/src/manifest/template-validator.test.ts +1 -1
  36. package/src/manifest/validate-privileged.test.ts +1 -1
  37. package/src/manifest/validate.test.ts +68 -29
  38. package/src/manifest/validate.ts +14 -1
  39. package/src/module/import.test.ts +7 -7
  40. package/src/module/packaging/build.test.ts +1 -1
  41. package/src/module/packaging/build.ts +16 -2
  42. package/src/packaging/nfpm-depends.test.ts +42 -0
  43. package/src/policy/capability-shape-baseline.ts +7 -1
  44. package/src/policy/fixture-capability-coverage.test.ts +5 -5
  45. package/src/policy/module-business-baseline.ts +33 -4
  46. package/src/registry/client.test.ts +5 -4
  47. package/src/services/aspect-approvals.test.ts +2 -2
  48. package/src/services/aspect-reconcile.test.ts +2 -2
  49. package/src/services/aspect-runner.test.ts +4 -4
  50. package/src/services/audit/backups.test.ts +1 -1
  51. package/src/services/audit/capability-abi.test.ts +1 -1
  52. package/src/services/audit/module-configs.test.ts +1 -1
  53. package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
  54. package/src/services/bus-interview-park.test.ts +1 -1
  55. package/src/services/bus-secret-flow.test.ts +1 -1
  56. package/src/services/capability-table-rows.test.ts +20 -2
  57. package/src/services/celilo-events.test.ts +80 -6
  58. package/src/services/celilo-events.ts +115 -12
  59. package/src/services/celilo-mgmt-hooks.test.ts +6 -3
  60. package/src/services/cross-module-read.test.ts +2 -2
  61. package/src/services/deploy-validation.test.ts +6 -6
  62. package/src/services/dns-provider-backfill.test.ts +2 -2
  63. package/src/services/dns-registrations.test.ts +1 -1
  64. package/src/services/fleet-checks.test.ts +2 -2
  65. package/src/services/health-runner.test.ts +1 -1
  66. package/src/services/infrastructure-variable-resolver.test.ts +11 -11
  67. package/src/services/instance-ops.test.ts +2 -2
  68. package/src/services/module-deploy.dns-repoint.test.ts +1 -1
  69. package/src/services/module-pause-quiescence.test.ts +1 -1
  70. package/src/services/module-pause.test.ts +1 -1
  71. package/src/services/module-subscriptions.test.ts +2 -2
  72. package/src/services/module-types-generator.test.ts +1 -1
  73. package/src/services/module-validator/contract-version.test.ts +3 -3
  74. package/src/services/network-ensure.test.ts +1 -1
  75. package/src/services/proxmox-reconcile.test.ts +2 -2
  76. package/src/services/remove-guard.test.ts +1 -1
  77. package/src/services/update/dep-graph.test.ts +1 -1
  78. package/src/services/update/orchestrator.test.ts +1 -1
  79. package/src/variables/capability-self-ref.test.ts +2 -2
  80. package/src/variables/computed/computed-integration.test.ts +1 -1
  81. package/src/variables/declarative-derivation.test.ts +19 -19
@@ -36,6 +36,7 @@ import {
36
36
  NETWORK_ZONES,
37
37
  capabilities,
38
38
  modules,
39
+ privateRoutes,
39
40
  secrets,
40
41
  systemConfig,
41
42
  webRoutes,
@@ -45,7 +46,10 @@ import { decryptSecret } from '../secrets/encryption';
45
46
  import { getOrCreateMasterKey } from '../secrets/master-key';
46
47
  import { buildControlPlaneApi } from '../services/api-principal-enrolment';
47
48
  import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
48
- import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
49
+ import {
50
+ emitPrivateRoutesChangedAndWait,
51
+ emitWebRoutesChangedAndWait,
52
+ } from '../services/celilo-events';
49
53
  import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from '../services/deployed-systems';
50
54
  import { withDnsInternalLedger } from '../services/dns-internal-records';
51
55
  import { withDnsRegistrationLedger } from '../services/dns-registrations';
@@ -95,12 +99,33 @@ import { loadHookConfigMap } from './load-hook-config';
95
99
  * success anyway, so a consumer could report "ready" while caddy never learned
96
100
  * the hostname — no site block, no cert, TLS internal_error for clients.
97
101
  */
98
- async function requireProviderReconcile(consumingModuleId: string, what: string): Promise<void> {
99
- const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
102
+ /**
103
+ * Which ingress a reconcile is being required for. The two differ only in the
104
+ * event type they emit and the names their failures carry, so they share this
105
+ * function rather than each keeping a copy — a private ingress that silently
106
+ * stopped waiting would look exactly like one with nothing to wait for
107
+ * (providers-converge-declared-state, slice 2).
108
+ */
109
+ const INGRESS = {
110
+ public_web: { capability: 'public_web', provider: 'caddy', emit: emitWebRoutesChangedAndWait },
111
+ private_web: {
112
+ capability: 'private_web',
113
+ provider: 'caddy-internal',
114
+ emit: emitPrivateRoutesChangedAndWait,
115
+ },
116
+ } as const;
117
+
118
+ async function requireProviderReconcile(
119
+ consumingModuleId: string,
120
+ what: string,
121
+ ingress: keyof typeof INGRESS = 'public_web',
122
+ ): Promise<void> {
123
+ const { capability, provider, emit } = INGRESS[ingress];
124
+ const reconcile = await emit(consumingModuleId);
100
125
 
101
126
  if (reconcile.noDispatcher) {
102
127
  throw new Error(
103
- `public_web ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
128
+ `${capability} ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (${provider}): no event dispatcher is running, so ${provider} never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
104
129
  );
105
130
  }
106
131
  // The handler's own words, when it left any. A crash is retried, so its
@@ -110,13 +135,20 @@ async function requireProviderReconcile(consumingModuleId: string, what: string)
110
135
  const because = reconcile.lastError ? ` The provider reported: ${reconcile.lastError}` : '';
111
136
 
112
137
  if (reconcile.timedOut) {
138
+ // "0 ok, 0 failed" reads as "nobody consumed it", which sends the reader to
139
+ // the dispatcher (celilo#1398/#1399) when the truth is that the provider's
140
+ // deliveries were STILL RUNNING at the deadline (celilo#1416). Say which.
141
+ const inFlight =
142
+ reconcile.stillRunning > 0
143
+ ? `${reconcile.stillRunning} delivery(ies) still running`
144
+ : `${reconcile.succeeded} ok, ${reconcile.failed} failed`;
113
145
  throw new Error(
114
- `public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live.${because}`,
146
+ `${capability} reconcile for ${consumingModuleId} (${what}) did not finish within its ${Math.round(reconcile.budgetMs / 1000)}s budget (${inFlight} of ${reconcile.events} change event(s)) — ${provider} did not confirm the change is live.${because}`,
115
147
  );
116
148
  }
117
149
  if (reconcile.failed > 0) {
118
150
  throw new Error(
119
- `public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared.${because}`,
151
+ `${capability} reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — ${provider} could not apply the change, so the hostname is not served as declared.${because}`,
120
152
  );
121
153
  }
122
154
  // ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
@@ -126,7 +158,7 @@ async function requireProviderReconcile(consumingModuleId: string, what: string)
126
158
  // changed and nobody served it — a failure.
127
159
  if (reconcile.events > 0 && reconcile.succeeded === 0) {
128
160
  throw new Error(
129
- `public_web ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a public_web provider (caddy) is deployed and subscribed.`,
161
+ `${capability} ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — ${provider} has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a ${capability} provider (${provider}) is deployed and subscribed.`,
130
162
  );
131
163
  }
132
164
  }
@@ -175,11 +207,13 @@ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFacto
175
207
  legacyFactoryName: 'default',
176
208
  },
177
209
  // MODULE-provided, unlike its sibling public_web above, which the framework
178
- // implements (createPublicWeb) against celilo's `web_routes` table. The
179
- // asymmetry is deliberate: a private route stored in `web_routes` would be
180
- // picked up by the PUBLIC caddy, which derives its served hostnames from
181
- // every row of that table — so the provider keeps its own routes and celilo
182
- // core holds no private-ingress business at all (celilo#846).
210
+ // implements (createPublicWeb). The two ingresses are separate TABLES, not
211
+ // separate owners: core owns `private_routes` as it owns `web_routes`
212
+ // (migration 0034), and that separation — not the provider keeping its own
213
+ // config — is what stops the PUBLIC caddy, which derives its served
214
+ // hostnames from every row of `web_routes`, from sweeping a private route
215
+ // into the public ingress (celilo#846). What stays module-side is the
216
+ // RENDER: caddy-internal decides what its Caddyfile says.
183
217
  private_web: {
184
218
  script: 'scripts/private-web-functions.ts',
185
219
  legacyFactoryName: 'default',
@@ -499,6 +533,26 @@ export async function loadCapabilityFunctions(
499
533
  // same seam. A jailed hook cannot write into its own web root, so a
500
534
  // module-provided web provider needs the state overlay to honor it.
501
535
  consumerStateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
536
+ // THE PRIVATE INGRESS'S DECLARED ROWS, for the provider that serves
537
+ // them. Scoped to private_web deliberately: these are one
538
+ // capability's declared state, and handing them to every
539
+ // module-provided capability would put the private route table in
540
+ // reach of modules that have no business reading it.
541
+ ...(capName === 'private_web'
542
+ ? {
543
+ privateRoutes: buildPrivateRouteOps(db),
544
+ // The same seam public_web has (see `onRoutesChanged` below):
545
+ // the capability persists the row and hands the converge to
546
+ // the provider's `reconcile_routes` subscription, then AWAITS
547
+ // that delivery so `registerReverseProxy` still returns only
548
+ // once the route is live (design D7). One primitive, two
549
+ // triggers — the subscription is the second, not a
550
+ // replacement for the call.
551
+ onRoutesChanged: async () => {
552
+ await requireProviderReconcile(consumingModuleId, 'route change', 'private_web');
553
+ },
554
+ }
555
+ : {}),
502
556
  });
503
557
  // Stamp here too, not only on the legacy path: a consumer that cannot
504
558
  // get what it needs must be able to name WHICH provider could not give
@@ -817,6 +871,51 @@ export async function loadCapabilityFunctions(
817
871
  debugLog('public_web: not registered in DB, skipping');
818
872
  }
819
873
 
874
+ // THE PRIVATE INGRESS, symmetric with the public one above (design D7 of
875
+ // providers-converge-declared-state). Its provider gets a view of the rows it
876
+ // serves and the converge that places what it renders — the same two things
877
+ // caddy gets, for the same reason, now that its routes are core-owned state
878
+ // rather than its own module-config JSON.
879
+ //
880
+ // Consumers never see either: only the module that PROVIDES private_web.
881
+ const privateWebProviders = db
882
+ .select()
883
+ .from(capabilities)
884
+ .where(eq(capabilities.capabilityName, 'private_web'))
885
+ .all();
886
+ const privateWebProvider = privateWebProviders[0];
887
+ if (privateWebProvider && consumingModuleId === privateWebProvider.moduleId) {
888
+ result.private_routes = buildPrivateRouteOps(db);
889
+ debugLog(`private_routes: injected for provider ${consumingModuleId}`);
890
+
891
+ const providerModuleId = privateWebProvider.moduleId;
892
+ const privateConverge: ProviderConvergeView = {
893
+ // The private ingress serves no consumer-built static sites today: every
894
+ // row it renders is a reverse proxy. Answering honestly rather than
895
+ // throwing keeps the shape identical to the public provider's, so one
896
+ // renderer contract covers both (design D1).
897
+ resolveConsumerSite: async (moduleId: string) => ({
898
+ unavailable: `the private ingress serves no static sites, so '${moduleId}' has none here`,
899
+ }),
900
+ converge: async (artifacts) => {
901
+ const { convergeProviderConfig, resolveStaticContentRetention } = await import(
902
+ '../services/provider-converge'
903
+ );
904
+ const result = await convergeProviderConfig(db, providerModuleId, {
905
+ ...artifacts,
906
+ retention: resolveStaticContentRetention(db, providerModuleId),
907
+ });
908
+ return {
909
+ success: result.success,
910
+ ...(result.error ? { error: result.error } : {}),
911
+ unresolved: result.unresolved,
912
+ };
913
+ },
914
+ };
915
+ result.provider_converge = privateConverge;
916
+ debugLog(`provider_converge: injected for private provider ${consumingModuleId}`);
917
+ }
918
+
820
919
  // Framework-granted, so unlike everything above there is no provider row to
821
920
  // look up and no script to import — celilo IS the management server whose
822
921
  // principals these are (web-ui-console D7b).
@@ -1488,6 +1587,74 @@ async function buildFirewallChain(
1488
1587
  return { chain: currentUpstream, self };
1489
1588
  }
1490
1589
 
1590
+ /**
1591
+ * Route ops for the private_web PROVIDER, over `private_routes`.
1592
+ *
1593
+ * The private ingress's rows are core-owned declared state now, exactly as
1594
+ * `web_routes` is for public_web (design D1 of
1595
+ * providers-converge-declared-state). They used to live in caddy-internal's
1596
+ * `routes` module-config JSON, which is why the two ingresses could not share
1597
+ * plumbing: celilo had nothing to render, converge or audit.
1598
+ *
1599
+ * SEPARATE TABLE, and that is the whole safety property. The public provider
1600
+ * derives the hostnames it serves from every row of ITS table, so a private
1601
+ * route sharing that table is one missing WHERE clause from being served
1602
+ * publicly with a public certificate (celilo#846). Injected only into the
1603
+ * private_web provider's own capability functions, the same way the route view
1604
+ * reaches caddy and no one else.
1605
+ *
1606
+ * Rows go out in the snake_case shape the provider's renderer already speaks,
1607
+ * so the module needs no translation layer and its existing route tests keep
1608
+ * asserting the same objects.
1609
+ */
1610
+ function buildPrivateRouteOps(db: DbClient) {
1611
+ const toRecord = (row: typeof privateRoutes.$inferSelect) => ({
1612
+ module_id: row.moduleId,
1613
+ slug: row.slug,
1614
+ type: row.type,
1615
+ path: row.path,
1616
+ hostname: row.hostname,
1617
+ ...(row.targetHost ? { target_host: row.targetHost } : {}),
1618
+ ...(row.targetPort ? { target_port: row.targetPort } : {}),
1619
+ ...(row.websocket ? { websocket: row.websocket } : {}),
1620
+ });
1621
+
1622
+ return {
1623
+ all: () => db.select().from(privateRoutes).all().map(toRecord),
1624
+ /**
1625
+ * Replace the whole set.
1626
+ *
1627
+ * The provider keeps the full route list in memory for the life of one
1628
+ * hook run and hands it back whole, which is the semantics its config key
1629
+ * had. Honouring that here keeps the change to the module small and the
1630
+ * two halves easy to reason about.
1631
+ *
1632
+ * ponytail: whole-set replace, in one transaction so a reader never sees
1633
+ * an empty ingress. If two providers ever write this table concurrently,
1634
+ * this becomes per-row upsert plus a delete of what the writer dropped.
1635
+ */
1636
+ replaceAll: (rows: ReturnType<typeof toRecord>[]) => {
1637
+ db.transaction((tx) => {
1638
+ tx.delete(privateRoutes).run();
1639
+ for (const row of rows) {
1640
+ tx.insert(privateRoutes)
1641
+ .values({
1642
+ slug: row.slug,
1643
+ moduleId: row.module_id,
1644
+ type: row.type,
1645
+ path: row.path,
1646
+ hostname: row.hostname,
1647
+ targetHost: row.target_host ?? null,
1648
+ targetPort: row.target_port ?? null,
1649
+ websocket: row.websocket ?? false,
1650
+ })
1651
+ .run();
1652
+ }
1653
+ });
1654
+ },
1655
+ };
1656
+ }
1657
+
1491
1658
  /**
1492
1659
  * Build RouteOps for the public_web capability implementation.
1493
1660
  * Wraps database operations so the capability never touches the DB directly.
@@ -529,7 +529,7 @@ describe('Hook Executor', () => {
529
529
  const result = await invokeHook(
530
530
  __dirname,
531
531
  'container_created',
532
- '1.0',
532
+ '2.0',
533
533
  { script: './test-fixtures/artifact-writing-hook.ts', timeout: 10000 },
534
534
  { vps_ip: '10.0.0.5' },
535
535
  {},
@@ -560,7 +560,7 @@ describe('Hook Executor', () => {
560
560
  const result = await invokeHook(
561
561
  __dirname,
562
562
  'container_created',
563
- '1.0',
563
+ '2.0',
564
564
  { script: './test-fixtures/success-hook.ts', timeout: 10000 },
565
565
  { vps_ip: '10.0.0.5' },
566
566
  {},
@@ -585,7 +585,7 @@ describe('Hook Executor', () => {
585
585
  const result = await invokeHook(
586
586
  __dirname,
587
587
  'container_created',
588
- '1.0',
588
+ '2.0',
589
589
  { script: './test-fixtures/success-hook.ts', timeout: 10000 },
590
590
  { vps_ip: '10.0.0.5' },
591
591
  {},
@@ -614,7 +614,7 @@ describe('Hook Executor', () => {
614
614
  const result = await invokeHook(
615
615
  __dirname,
616
616
  'container_created',
617
- '1.0',
617
+ '2.0',
618
618
  definition,
619
619
  { vps_ip: '10.0.0.5' },
620
620
  { username: 'test' },
@@ -641,7 +641,7 @@ describe('Hook Executor', () => {
641
641
  const result = await invokeHook(
642
642
  __dirname,
643
643
  'on_backup',
644
- '1.0',
644
+ '2.0',
645
645
  definition,
646
646
  {},
647
647
  {},
@@ -663,7 +663,7 @@ describe('Hook Executor', () => {
663
663
  const result = await invokeHook(
664
664
  __dirname,
665
665
  'container_created',
666
- '1.0',
666
+ '2.0',
667
667
  definition,
668
668
  {},
669
669
  {},
@@ -686,7 +686,7 @@ describe('Hook Executor', () => {
686
686
  const result = await invokeHook(
687
687
  __dirname,
688
688
  'on_backup',
689
- '1.0',
689
+ '2.0',
690
690
  definition,
691
691
  { backup_dir: '/tmp/backup' },
692
692
  {},
@@ -708,7 +708,7 @@ describe('Hook Executor', () => {
708
708
  const result = await invokeHook(
709
709
  __dirname,
710
710
  'container_created',
711
- '1.0',
711
+ '2.0',
712
712
  definition,
713
713
  {},
714
714
  {},
@@ -743,10 +743,10 @@ describe('Hook Executor', () => {
743
743
  const { logger } = createCapturingLogger();
744
744
  const definition: HookDefinition = { script: './test-fixtures/success-hook.ts' };
745
745
 
746
- const result = await invokeHook(__dirname, 'on_poop', '1.0', definition, {}, {}, {}, logger);
746
+ const result = await invokeHook(__dirname, 'on_poop', '2.0', definition, {}, {}, {}, logger);
747
747
 
748
748
  expect(result.success).toBe(false);
749
- expect(result.error).toContain("Hook 'on_poop' is not part of celilo_contract 1.0");
749
+ expect(result.error).toContain("Hook 'on_poop' is not part of celilo_contract 2.0");
750
750
  });
751
751
 
752
752
  test('pre-flight: fails when a required capability is missing', async () => {
@@ -759,7 +759,7 @@ describe('Hook Executor', () => {
759
759
  const result = await invokeHook(
760
760
  __dirname,
761
761
  'container_created',
762
- '1.0',
762
+ '2.0',
763
763
  definition,
764
764
  { vps_ip: '10.0.0.5' },
765
765
  {},
@@ -786,7 +786,7 @@ describe('Hook Executor', () => {
786
786
  const result = await invokeHook(
787
787
  __dirname,
788
788
  'container_created',
789
- '1.0',
789
+ '2.0',
790
790
  definition,
791
791
  { vps_ip: '10.0.0.5' },
792
792
  {},
@@ -813,7 +813,7 @@ describe('Hook Executor', () => {
813
813
  const result = await invokeHook(
814
814
  __dirname,
815
815
  'container_created',
816
- '1.0',
816
+ '2.0',
817
817
  definition,
818
818
  { vps_ip: '10.0.0.5' },
819
819
  {},
@@ -62,7 +62,7 @@ describe('celilo#1000: a hook is TOLD where its state directory is', () => {
62
62
  const result = await invokeHook(
63
63
  root,
64
64
  'on_install',
65
- '1.0',
65
+ '2.0',
66
66
  { script: 'hook.ts' },
67
67
  {},
68
68
  {},
@@ -97,7 +97,7 @@ describe('celilo#1000: a hook is TOLD where its state directory is', () => {
97
97
  const result = await invokeHook(
98
98
  root,
99
99
  'on_install',
100
- '1.0',
100
+ '2.0',
101
101
  { script: 'hook.ts' },
102
102
  {},
103
103
  {},
@@ -177,6 +177,34 @@ describe('~/.ssh is never in the mount set (stage 3, D12)', () => {
177
177
  });
178
178
  });
179
179
 
180
+ describe('a bound browser without fonts is a browser that cannot render', () => {
181
+ // celilo#1422. Binding BROWSER_ROOT makes the browser START; it does not make
182
+ // it RENDER. With no font configuration in the jail, Blink's remote font face
183
+ // path hits a fatal NOTREACHED on any page that loads a web font and the
184
+ // renderer dies mid navigation — surfacing as a Playwright selector timeout
185
+ // that names neither fonts nor the jail. Measured on the management host,
186
+ // same bwrap invocation, only these rows differing: 12 NOTREACHED without,
187
+ // 0 with.
188
+ //
189
+ // Tied to BROWSER_ROOT deliberately. Fonts are only interesting BECAUSE a
190
+ // browser is bound, so if the browser bind is ever dropped this test should
191
+ // be reconsidered with it rather than left asserting an unrelated directory.
192
+ test('every font directory the renderer needs is bound alongside the browser', () => {
193
+ const paths = pathsOf(BASE);
194
+ expect(paths).toContain(BROWSER_ROOT);
195
+ for (const dir of ['/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts']) {
196
+ expect(paths).toContain(dir);
197
+ }
198
+ });
199
+
200
+ test('fonts are read-only — a hook has no business writing to them', () => {
201
+ for (const dir of ['/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts']) {
202
+ const row = deriveMountSet(BASE).entries.find((e) => e.path === dir);
203
+ expect(row?.mode).toBe('ro');
204
+ }
205
+ });
206
+ });
207
+
180
208
  describe('the fleet browser is reachable, and only read-only (task 4.10)', () => {
181
209
  test('BROWSER_ROOT is bound', () => {
182
210
  // Task 4.10 names `~/.cache/ms-playwright`. That path is stale:
@@ -112,6 +112,34 @@ export interface MountSetRequest {
112
112
  /** Directories whose contents the runtime needs in order to start at all. */
113
113
  const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
114
114
 
115
+ /**
116
+ * What RENDERING needs, as distinct from what running needs.
117
+ *
118
+ * Omitting these does not stop the browser starting, which is why it survived
119
+ * the bind added for `BROWSER_ROOT` below. It kills the RENDERER, and only on a
120
+ * page that loads a web font. Blink's remote font face path hits a fatal
121
+ * `NOTREACHED` with no font configuration present, the renderer dies mid
122
+ * navigation, and Playwright reports `Target page, context or browser has been
123
+ * closed` while awaiting a selector — which reads as a slow page or a flaky
124
+ * selector and names neither fonts nor the jail.
125
+ *
126
+ * Measured 2026-09-25 on the management host, same bwrap invocation, only these
127
+ * three rows differing (celilo#1422):
128
+ *
129
+ * without: 12x ERROR ... remote_font_face_source.cc:365] NOTREACHED hit.
130
+ * with: 0
131
+ *
132
+ * celilo PROVISIONS these deliberately — `celilo-mgmt`'s browser block installs
133
+ * `fontconfig` and `fonts-dejavu-core` because "a screenshot with no glyphs is
134
+ * not worth retaining" (D7). So the fleet already pays for fonts and the jail
135
+ * was hiding them from the one process that needs them: the provisioning and
136
+ * the mount set disagreed, and the browser lost.
137
+ *
138
+ * Read-only, and absent rows are dropped like any other, so a host with no
139
+ * fonts installed is exactly as it was rather than newly fatal.
140
+ */
141
+ const FONT_DIRS = ['/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts'] as const;
142
+
115
143
  /**
116
144
  * What resolving a hostname needs. Read-only, and absent ones are dropped.
117
145
  *
@@ -220,6 +248,11 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
220
248
  for (const dir of RUNTIME_SUPPORT_DIRS) {
221
249
  entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime'));
222
250
  }
251
+ for (const dir of FONT_DIRS) {
252
+ entries.push(
253
+ entry(dir, 'ro', 'fonts — the renderer dies without them, see FONT_DIRS', 'runtime'),
254
+ );
255
+ }
223
256
  for (const file of RESOLVER_FILES) {
224
257
  entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime'));
225
258
  }
@@ -1,4 +1,4 @@
1
- import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
1
+ import type { HookStore } from '@celilo/capabilities';
2
2
 
3
3
  /**
4
4
  * In-memory hook-owned-state stores for tests that build a HookContext
@@ -16,10 +16,12 @@ export function configStore(
16
16
  return attach(values);
17
17
  }
18
18
 
19
- export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
19
+ export function secretStore(
20
+ values: Record<string, string> = {},
21
+ ): Record<string, string> & HookStore {
20
22
  // Downcast: the caller passed Record<string, string>; attach only adds
21
23
  // methods and never writes, so the value type is unchanged.
22
- return attach(values) as HookStoreBackedMap;
24
+ return attach(values) as Record<string, string> & HookStore;
23
25
  }
24
26
 
25
27
  function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
@@ -5,13 +5,15 @@
5
5
  * manifest's `celilo_contract` field selects which contract applies; the
6
6
  * registry is the single source of truth for what each version promises.
7
7
  *
8
- * To mint a new contract version: add a new file (e.g. `./v2.ts`), import its
9
- * contract object here, and register it in `CONTRACTS`. Old contracts stay
10
- * registered indefinitely so legacy modules continue to validate.
8
+ * To mint a new contract version: add a new file (e.g. `./v3.ts`), import its
9
+ * contract object here, and register it in `CONTRACTS`. A version whose
10
+ * modules still work stays registered; a version whose modules would now FAIL
11
+ * SILENTLY moves to `RETIRED_CONTRACT_VERSIONS` with the reason, so the
12
+ * validator refuses it by name rather than dropping state at deploy time.
11
13
  */
12
14
 
13
- import { V1_CONTRACT } from './v1';
14
15
  import type { ContractHookSignature, ContractHooks } from './v1';
16
+ import { V2_CONTRACT } from './v2';
15
17
 
16
18
  export type { ContractHooks, ContractHookSignature };
17
19
 
@@ -26,7 +28,7 @@ export type { ContractHooks, ContractHookSignature };
26
28
  * `ContractHooks` stays exact.
27
29
  *
28
30
  * Returns `undefined` for a name the contract does not define; the caller
29
- * turns that into "Hook 'x' is not part of celilo_contract 1.0".
31
+ * turns that into "Hook 'x' is not part of celilo_contract 2.0".
30
32
  */
31
33
  export function contractHookSignature(
32
34
  hooks: ContractHooks,
@@ -53,16 +55,35 @@ export interface Contract {
53
55
  * `SUPPORTED_CONTRACT_VERSIONS` below so Zod's enum picks it up.
54
56
  */
55
57
  export const CONTRACTS: Record<string, Contract> = {
56
- '1.0': V1_CONTRACT,
58
+ '2.0': V2_CONTRACT,
59
+ };
60
+
61
+ /**
62
+ * Versions a manifest may still NAME but that no longer validate, mapped to
63
+ * the reason. A retired version is kept here rather than simply deleted so
64
+ * the validator can say what broke and how to move, instead of reporting a
65
+ * known version as an unrecognised string.
66
+ */
67
+ export const RETIRED_CONTRACT_VERSIONS: Record<string, string> = {
68
+ '1.0':
69
+ 'contract 1.0 persisted a hook\'s returned record as the module\'s secrets and config. That channel is removed (openspec/changes/hook-owned-state D5) — a 1.0 hook would deploy successfully and store nothing. Declare celilo_contract "2.0" and persist state with context.config.set / context.secrets.set against names the manifest declares.',
57
70
  };
58
71
 
59
72
  /**
60
73
  * Const tuple of supported contract version strings, used to drive the Zod
61
74
  * enum that validates the manifest's `celilo_contract` field.
62
75
  */
63
- export const SUPPORTED_CONTRACT_VERSIONS = ['1.0'] as const;
76
+ export const SUPPORTED_CONTRACT_VERSIONS = ['2.0'] as const;
64
77
  export type SupportedContractVersion = (typeof SUPPORTED_CONTRACT_VERSIONS)[number];
65
78
 
79
+ /**
80
+ * Every version string a manifest is allowed to WRITE — supported plus
81
+ * retired. The schema enum accepts these so a retired version reaches
82
+ * `validateHookContract`, which explains the retirement; anything else is a
83
+ * plain unknown-version error from Zod.
84
+ */
85
+ export const KNOWN_CONTRACT_VERSIONS = ['1.0', '2.0'] as const;
86
+
66
87
  /**
67
88
  * Look up a contract by version string.
68
89
  *
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Celilo Module Contract — v2.0
3
+ *
4
+ * Hook SIGNATURES are unchanged from v1.0 — `V2_HOOKS` is `V1_HOOKS`. What
5
+ * changed is what the framework does with a hook's return value: nothing.
6
+ *
7
+ * Under v1.0, a record returned from `validate_config`, `on_install` or
8
+ * `container_created` was swept back into the module's secrets (and, for
9
+ * validate_config, its config). That was the only write channel a hook had,
10
+ * so a hook that needed to persist something it discovered had to return it.
11
+ * The channel is gone (openspec/changes/hook-owned-state, D5): a hook now
12
+ * persists state explicitly through `context.config.set` / `context.secrets.set`,
13
+ * against names its manifest declares.
14
+ *
15
+ * This is a MAJOR bump rather than a v1.x clarification because a module
16
+ * written against the old channel keeps deploying and silently stores
17
+ * nothing — the deploy reports success and the state is dropped. Refusing
18
+ * `celilo_contract: "1.0"` outright (see `RETIRED_CONTRACT_VERSIONS`) turns
19
+ * that silent loss into a message naming the fix, which is the whole point of
20
+ * versioning the contract.
21
+ */
22
+
23
+ import { V1_HOOKS } from './v1';
24
+ import type { ContractHooks } from './v1';
25
+
26
+ /** v2.0 hook signatures — identical to v1.0. The break is behavioural. */
27
+ export const V2_HOOKS: ContractHooks = V1_HOOKS;
28
+
29
+ export const V2_CONTRACT = {
30
+ version: '2.0' as const,
31
+ hooks: V2_HOOKS,
32
+ };
@@ -11,7 +11,7 @@ import { ModuleManifestSchema } from './schema';
11
11
 
12
12
  function manifestWith(icon?: string): Record<string, unknown> {
13
13
  return {
14
- celilo_contract: '1.0',
14
+ celilo_contract: '2.0',
15
15
  id: 'icon-fixture',
16
16
  name: 'Icon Fixture',
17
17
  version: '1.0.0',
@@ -6,7 +6,7 @@ import {
6
6
  MONITOR_INTERVAL_FLOOR_MINUTES,
7
7
  cadenceSchema,
8
8
  } from '../services/cadence';
9
- import { SUPPORTED_CONTRACT_VERSIONS } from './contracts';
9
+ import { KNOWN_CONTRACT_VERSIONS } from './contracts';
10
10
 
11
11
  /**
12
12
  * Variable declaration sources
@@ -610,7 +610,18 @@ export const ModuleManifestSchema = z
610
610
  * here so a future v2 contract can change requirements without breaking
611
611
  * v1 modules.
612
612
  */
613
- celilo_contract: z.enum(SUPPORTED_CONTRACT_VERSIONS),
613
+ /**
614
+ * The enum accepts RETIRED versions as well as supported ones. Refusing a
615
+ * retired version is a POLICY about importing a manifest, and it lives in
616
+ * `validateManifest`, not here: this schema is also what re-parses the
617
+ * manifest celilo already STORED for an installed module
618
+ * (`module-pause.ts`, `module-remove.ts`, `provider-arrival.ts`), and each
619
+ * of those `continue`s past a parse failure. Refusing here would make
620
+ * every still-unmigrated installed module silently vanish from the
621
+ * dependent-removal check and the provider backfill — a narrowing with no
622
+ * error, which is the exact failure mode this change exists to remove.
623
+ */
624
+ celilo_contract: z.enum(KNOWN_CONTRACT_VERSIONS),
614
625
 
615
626
  id: z
616
627
  .string()
@@ -849,6 +860,20 @@ export const ModuleManifestSchema = z
849
860
  command: z.string().min(1).optional(),
850
861
  /** Path to a build script (relative to module directory). Mutually exclusive with command. */
851
862
  script: z.string().min(1).optional(),
863
+ /**
864
+ * Wall-clock budget for the build, in seconds (celilo#1252).
865
+ *
866
+ * The packager used to kill every build at a hardcoded 300s. A module
867
+ * whose build command declares its own retry budget — forgejo fetches
868
+ * two release archives from codeberg with `curl --retry` — could
869
+ * express far more wall clock than that cap allowed, and the retries
870
+ * written for a flaky upstream never ran. The cap is a property of the
871
+ * fetch the module performs, so the module names it.
872
+ *
873
+ * `packages/e2e/tests/module-build-budget-fits-timeout.test.ts` fails
874
+ * when a build command's worst-case curl budget exceeds this value.
875
+ */
876
+ timeout_seconds: z.number().int().positive().max(7200).default(300),
852
877
  artifacts: z
853
878
  .array(
854
879
  z
@@ -14,7 +14,7 @@ import {
14
14
  */
15
15
  function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest {
16
16
  return {
17
- celilo_contract: '1.0',
17
+ celilo_contract: '2.0',
18
18
  id: 'test-module',
19
19
  name: 'Test Module',
20
20
  version: '1.0.0',