@celilo/cli 2.2.1 → 3.0.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 (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createHash } from 'node:crypto';
14
- import { existsSync, readFileSync } from 'node:fs';
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
16
  import type { CapabilityTableDeclaration, CapabilityTables } from '@celilo/capabilities';
17
17
  import ts from 'typescript';
@@ -95,6 +95,216 @@ function printedInterface(file: string, typeName: string): string {
95
95
  throw new Error(`${typeName} not found in ${file}`);
96
96
  }
97
97
 
98
+ // --- REQUEST / RESULT TYPES -------------------------------------------------
99
+ //
100
+ // The interface hash above references its request and result types by NAME
101
+ // only, so an edit to `PublishStaticSiteRequest` never moved a hash. That is
102
+ // the exact hole celilo#1361 fell through: `sourceDir` was removed from three
103
+ // request types, no contract version moved, and every new consumer build died
104
+ // against the deployed providers. Everything below resolves those types and
105
+ // hashes their members, so a request-contract change trips the gate like any
106
+ // interface change — and the recorded member text lets the regenerate script
107
+ // tell a breaking member diff (bump the MAJOR) from an additive one (minor).
108
+
109
+ interface DeclaredType {
110
+ file: string;
111
+ decl: ts.InterfaceDeclaration | ts.TypeAliasDeclaration;
112
+ source: ts.SourceFile;
113
+ }
114
+
115
+ /** All interface and type-alias declarations in one capabilities src file. */
116
+ function declaredTypes(file: string): Map<string, DeclaredType> {
117
+ const out = new Map<string, DeclaredType>();
118
+ if (!existsSync(file)) return out;
119
+ const source = parse(file);
120
+ for (const statement of source.statements) {
121
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
122
+ out.set(statement.name.text, { file, decl: statement, source });
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Named relative imports of one file: local name → the file it came from. */
129
+ function importsOf(file: string): Map<string, string> {
130
+ const out = new Map<string, string>();
131
+ if (!existsSync(file)) return out;
132
+ const source = parse(file);
133
+ for (const statement of source.statements) {
134
+ if (!ts.isImportDeclaration(statement)) continue;
135
+ const specifier = (statement.moduleSpecifier as ts.StringLiteral).text;
136
+ if (!specifier.startsWith('./')) continue;
137
+ const target = join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`);
138
+ const bindings = statement.importClause?.namedBindings;
139
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
140
+ for (const element of bindings.elements) {
141
+ out.set(element.name.text, target);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** Type-reference identifiers reachable from one declaration. */
148
+ function referencedTypeNames(decl: ts.Declaration): Set<string> {
149
+ const out = new Set<string>();
150
+ const walk = (node: ts.Node): void => {
151
+ if (ts.isTypeReferenceNode(node)) {
152
+ if (ts.isIdentifier(node.typeName)) out.add(node.typeName.text);
153
+ } else if (ts.isExpressionWithTypeArguments(node)) {
154
+ if (ts.isIdentifier(node.expression)) out.add(node.expression.text);
155
+ }
156
+ ts.forEachChild(node, (child) => walk(child));
157
+ };
158
+ walk(decl);
159
+ return out;
160
+ }
161
+
162
+ const shapePrinter = ts.createPrinter({ removeComments: true });
163
+
164
+ /** One member of a request type, in the form the baseline records and diffs. */
165
+ export interface RequestMemberShape {
166
+ name: string;
167
+ optional: boolean;
168
+ /** Printed member type, comments stripped. */
169
+ type: string;
170
+ }
171
+
172
+ /** One request or result type a capability's interface can reach. */
173
+ export interface RequestTypeShape {
174
+ name: string;
175
+ /** 'alias' types hash as one synthetic `_self` member. */
176
+ kind: 'interface' | 'alias';
177
+ members: RequestMemberShape[];
178
+ }
179
+
180
+ function membersOfDecl(decl: DeclaredType): RequestMemberShape[] {
181
+ if (ts.isTypeAliasDeclaration(decl.decl)) {
182
+ return [
183
+ {
184
+ name: '_self',
185
+ optional: false,
186
+ type: shapePrinter.printNode(ts.EmitHint.Unspecified, decl.decl.type, decl.source),
187
+ },
188
+ ];
189
+ }
190
+ const members: RequestMemberShape[] = [];
191
+ for (const member of decl.decl.members) {
192
+ if (!member.name) continue;
193
+ const typeNode =
194
+ ts.isPropertySignature(member) || ts.isMethodSignature(member) ? member.type : undefined;
195
+ members.push({
196
+ name: member.name.getText(decl.source),
197
+ optional: Boolean(member.questionToken),
198
+ type: typeNode
199
+ ? shapePrinter.printNode(ts.EmitHint.Unspecified, typeNode, decl.source)
200
+ : shapePrinter.printNode(ts.EmitHint.Unspecified, member, decl.source),
201
+ });
202
+ }
203
+ return members.sort((a, b) => a.name.localeCompare(b.name));
204
+ }
205
+
206
+ const declsCache = new Map<string, Map<string, DeclaredType>>();
207
+
208
+ function declsOf(file: string): Map<string, DeclaredType> {
209
+ let cached = declsCache.get(file);
210
+ if (!cached) {
211
+ cached = declaredTypes(file);
212
+ declsCache.set(file, cached);
213
+ }
214
+ return cached;
215
+ }
216
+
217
+ function allSrcFiles(): string[] {
218
+ return readdirSync(CAPABILITIES_SRC)
219
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
220
+ .map((name) => join(CAPABILITIES_SRC, name));
221
+ }
222
+
223
+ /**
224
+ * Resolve a referenced type name from the file that references it: the file's
225
+ * own declarations first, then its named imports, then a unique export
226
+ * anywhere in packages/capabilities/src. An unresolved name is not contract
227
+ * surface this repo owns (primitives, DOM types) and is ignored; an ambiguous
228
+ * one would hash the wrong declaration, so it refuses rather than guessing.
229
+ */
230
+ function resolveName(name: string, fromFile: string): DeclaredType | undefined {
231
+ const local = declsOf(fromFile).get(name);
232
+ if (local) return local;
233
+ const imported = importsOf(fromFile).get(name);
234
+ if (imported) {
235
+ const found = declsOf(imported).get(name);
236
+ if (found) return found;
237
+ }
238
+ const hits: DeclaredType[] = [];
239
+ for (const file of allSrcFiles()) {
240
+ const found = declsOf(file).get(name);
241
+ const exported = found?.decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
242
+ if (found && exported) hits.push(found);
243
+ }
244
+ if (hits.length === 1) return hits[0];
245
+ if (hits.length > 1) {
246
+ throw new Error(
247
+ `capability-shape: type name '${name}' is declared in ${hits.length} files (${hits.map((h) => h.file).join(', ')}), so its request shape cannot be resolved unambiguously. Rename one of them.`,
248
+ );
249
+ }
250
+ return undefined;
251
+ }
252
+
253
+ /**
254
+ * Every request/result type reachable from a capability's interface. Names
255
+ * that resolve to nothing in packages/capabilities/src (primitives, DOM
256
+ * types) are ignored — they are not contract surface this repo owns.
257
+ */
258
+ export function requestTypes(capability: string): RequestTypeShape[] {
259
+ const subject = capabilitySubjects().get(capability);
260
+ if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
261
+
262
+ const start = parse(subject.file);
263
+ let iface: ts.InterfaceDeclaration | undefined;
264
+ for (const statement of start.statements) {
265
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === subject.typeName) {
266
+ iface = statement;
267
+ }
268
+ }
269
+ if (!iface) throw new Error(`${subject.typeName} not found in ${subject.file}`);
270
+
271
+ const visited = new Set<string>();
272
+ const shapes: RequestTypeShape[] = [];
273
+ const queue: Array<{ name: string; fromFile: string }> = [
274
+ { name: subject.typeName, fromFile: subject.file },
275
+ ];
276
+ while (queue.length > 0) {
277
+ const { name, fromFile } = queue.pop() as { name: string; fromFile: string };
278
+ if (visited.has(name)) continue;
279
+ visited.add(name);
280
+ const decl = resolveName(name, fromFile);
281
+ if (!decl) continue;
282
+ const members = membersOfDecl(decl);
283
+ shapes.push({
284
+ name,
285
+ kind: ts.isTypeAliasDeclaration(decl.decl) ? 'alias' : 'interface',
286
+ members,
287
+ });
288
+ const refs = referencedTypeNames(decl.decl);
289
+ for (const ref of refs) {
290
+ if (visited.has(ref)) continue;
291
+ const resolved = resolveName(ref, decl.file);
292
+ if (resolved) queue.push({ name: ref, fromFile: resolved.file });
293
+ else visited.add(ref);
294
+ }
295
+ }
296
+ return shapes.sort((a, b) => a.name.localeCompare(b.name));
297
+ }
298
+
299
+ /**
300
+ * The canonical request-shape text: the baseline's `requests` field, the diff
301
+ * input for the regenerate guard, and part of the shape hash. Sorted so member
302
+ * or type reordering is not a shape change.
303
+ */
304
+ export function requestsKey(capability: string): string {
305
+ return canonical(requestTypes(capability));
306
+ }
307
+
98
308
  /** Every `* satisfies CapabilityTables` const exported from a capability's module. */
99
309
  export async function tablesOf(file: string): Promise<CapabilityTables> {
100
310
  const loaded = (await import(file)) as Record<string, unknown>;
@@ -125,5 +335,76 @@ export async function shapeHash(capability: string): Promise<string> {
125
335
  if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
126
336
  const iface = printedInterface(subject.file, subject.typeName);
127
337
  const tables = canonical(await tablesOf(subject.file));
128
- return createHash('sha256').update(`${iface}\n--tables--\n${tables}`).digest('hex');
338
+ // Requests fold into the SAME hash as the interface: a request-contract
339
+ // change must move the version exactly like an interface change, and the
340
+ // regenerate script records `requests` beside the hash so it can tell the
341
+ // breaking member diff from an additive one (see classifyRequestChange).
342
+ const requests = requestsKey(capability);
343
+ return createHash('sha256')
344
+ .update(`${iface}\n--tables--\n${tables}\n--requests--\n${requests}`)
345
+ .digest('hex');
346
+ }
347
+
348
+ /** How a capability's request-shape text changed from one recording to the next. */
349
+ export type RequestChangeClassification =
350
+ | { kind: 'unchanged' }
351
+ | { kind: 'additive'; note: string }
352
+ | { kind: 'breaking'; reasons: string[] };
353
+
354
+ /**
355
+ * Classify a request-shape change between a baseline recording and the
356
+ * current code. ONLY these are breaking (the bump rule in
357
+ * capability-contract.ts):
358
+ * - a member was removed (old providers reject the request; old consumers
359
+ * stop compiling)
360
+ * - a member went optional → required (consumers that omitted it break)
361
+ * - a member's type changed (cannot tell widened from narrowed here, so
362
+ * treated as breaking; pass --non-breaking to the regenerate script
363
+ * when review agrees the widening is safe)
364
+ * Everything else — adding an optional member, relaxing required → optional —
365
+ * is additive and takes a minor per the bump rules.
366
+ */
367
+ export function classifyRequestChange(
368
+ before: RequestTypeShape[],
369
+ after: RequestTypeShape[],
370
+ ): RequestChangeClassification {
371
+ const beforeByName = new Map(before.map((t) => [t.name, t]));
372
+ const afterByName = new Map(after.map((t) => [t.name, t]));
373
+ const reasons: string[] = [];
374
+ const additions: string[] = [];
375
+ for (const [name, oldType] of beforeByName) {
376
+ const newType = afterByName.get(name);
377
+ if (!newType) {
378
+ // A type leaving the reachable set is an interface change, which the
379
+ // interface hash and its own bump cover. Not a request-level break.
380
+ continue;
381
+ }
382
+ const oldMembers = new Map(oldType.members.map((m) => [m.name, m]));
383
+ const newMembers = new Map(newType.members.map((m) => [m.name, m]));
384
+ for (const [memberName, oldMember] of oldMembers) {
385
+ const newMember = newMembers.get(memberName);
386
+ if (!newMember) {
387
+ reasons.push(`${name}.${memberName} was removed`);
388
+ } else {
389
+ if (!oldMember.optional && newMember.optional) {
390
+ additions.push(`${name}.${memberName} became optional`);
391
+ } else if (oldMember.optional && !newMember.optional) {
392
+ reasons.push(`${name}.${memberName} went optional → required`);
393
+ } else if (oldMember.type !== newMember.type) {
394
+ reasons.push(
395
+ `${name}.${memberName} changed type (${oldMember.type} → ${newMember.type})`,
396
+ );
397
+ }
398
+ }
399
+ }
400
+ for (const newMember of newMembers.values()) {
401
+ if (!oldMembers.has(newMember.name)) {
402
+ if (newMember.optional) additions.push(`${name}.${newMember.name} added (optional)`);
403
+ else reasons.push(`${name}.${newMember.name} added as REQUIRED`);
404
+ }
405
+ }
406
+ }
407
+ if (reasons.length > 0) return { kind: 'breaking', reasons };
408
+ if (additions.length > 0) return { kind: 'additive', note: additions.join('; ') };
409
+ return { kind: 'unchanged' };
129
410
  }
@@ -82,28 +82,36 @@ export const SWALLOWED_REFUSAL_RULE = 'swallowed framework refusal';
82
82
  */
83
83
  const CLI_SPAWN_RE =
84
84
  /\b(?:execSync|execFileSync|execFile|exec|spawnSync|spawn)\s*\(\s*(?:['"`]\s*celilo(?=\s|['"`])|`[^`]*\bcelilo(?=\s|`))/;
85
+
86
+ /**
87
+ * The spawn rule runs against the WHOLE FILE, not one line at a time. The
88
+ * per-line pass above could not see a call whose argv wrapped onto the next
89
+ * line — `execFileSync(\n 'celilo', …` — and that blind spot was live:
90
+ * celilo-registry setup.ts carried a four-line spawn and iptables
91
+ * firewall-functions.ts three more, all scanning clean while the gate was
92
+ * green (found by ce-y0we's reach check on 2026-09-09). The regex itself
93
+ * needs no change — `\s*` already crosses newlines, and `[^\`]*` never
94
+ * matches a backtick so a template cannot swallow the rest of the file — it
95
+ * just has to be applied to text wider than a line. Line numbers come from
96
+ * the match index, so reports still name the line an author fixes.
97
+ */
98
+ const CLI_SPAWN_BLOCK_RE = new RegExp(CLI_SPAWN_RE.source, 'g');
99
+
100
+ /** Same wording the rule carried when it ran per line; moved with the pass. */
101
+ const CLI_SPAWN_HINT =
102
+ 'A jailed hook has no celilo binary and no shell, so this dies with ' +
103
+ '"Executable not found in $PATH" (celilo#1225). Replace the spawn with an ' +
104
+ 'injected capability — context.secrets/context.config for self-config and ' +
105
+ 'self-secrets (openspec/changes/hook-owned-state) — or move the operation ' +
106
+ 'into the framework the way celilo-mgmt on_install did (5e0e624d).';
107
+
85
108
  const FRAMEWORK_CALL_RE = new RegExp(`${CLI_SPAWN_RE.source}|\\brunCelilo\\s*\\(`);
86
109
 
87
110
  const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
88
- {
89
- rule: JAILED_CLI_SPAWN_RULE,
90
- // Same regex as CLI_SPAWN_RE below — one definition of "reaches the
91
- // framework", used by both rules that key on it. Matches the CLI as
92
- // argv[0] of a spawn-family call ('celilo' / "celilo"), or inside a shell
93
- // string passed to one, where celilo is followed by a word (a subcommand)
94
- // or the closing quote — so `docker exec celilo-mgr …`, a `celilo.db` file
95
- // name, and prose that merely names the CLI do not fire. What it
96
- // deliberately cannot see: a binary path resolved into a variable, or a
97
- // spawn reached through an indirection. Same honesty class as the
98
- // namespace rule — a lint, not a boundary.
99
- re: CLI_SPAWN_RE,
100
- hint:
101
- 'A jailed hook has no celilo binary and no shell, so this dies with ' +
102
- '"Executable not found in $PATH" (celilo#1225). Replace the spawn with an ' +
103
- 'injected capability — context.secrets/context.config for self-config and ' +
104
- 'self-secrets (openspec/changes/hook-owned-state) — or move the operation ' +
105
- 'into the framework the way celilo-mgmt on_install did (5e0e624d).',
106
- },
111
+ // NOTE: the jailed-CLI-spawn rule is NOT in this array — it runs in its own
112
+ // whole-file pass in scanModuleScriptSource (CLI_SPAWN_BLOCK_RE), because a
113
+ // per-line match missed every spawn whose argv wrapped. Everything else
114
+ // stays per-line.
107
115
  {
108
116
  rule: 'raw ssh invocation (StrictHostKeyChecking)',
109
117
  re: /StrictHostKeyChecking/,
@@ -200,6 +208,20 @@ export function scanModuleScriptSource(file: string, source: string): ScanViolat
200
208
  const violations: ScanViolation[] = [];
201
209
  const lines = source.split('\n');
202
210
 
211
+ // The jailed-CLI-spawn pass: whole-file, so a spawn whose argv wraps onto
212
+ // the next line is still seen (see CLI_SPAWN_BLOCK_RE for the blind spot
213
+ // that motivated this). Reported on the line the CALL starts, because that
214
+ // is the site an author fixes.
215
+ CLI_SPAWN_BLOCK_RE.lastIndex = 0;
216
+ for (let m = CLI_SPAWN_BLOCK_RE.exec(source); m !== null; m = CLI_SPAWN_BLOCK_RE.exec(source)) {
217
+ violations.push({
218
+ file,
219
+ line: source.slice(0, m.index).split('\n').length,
220
+ rule: JAILED_CLI_SPAWN_RULE,
221
+ hint: CLI_SPAWN_HINT,
222
+ });
223
+ }
224
+
203
225
  lines.forEach((text, i) => {
204
226
  for (const { rule, re, hint } of PATTERN_RULES) {
205
227
  if (re.test(text)) violations.push({ file, line: i + 1, rule, hint });
@@ -337,51 +359,19 @@ export interface PolicyDebtEntry {
337
359
  * the test goes red, and the entry must be deleted in the same change.
338
360
  */
339
361
  export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
340
- {
341
- module: 'celilo-mgmt',
342
- file: 'scripts/on_backup.ts',
343
- matches: 1,
344
- reason:
345
- 'hook-owned-state 5.5 / survey class G: machine list becomes a machine-pool read capability',
346
- },
347
362
  {
348
363
  module: 'dnsmasq-dhcp',
349
364
  file: 'scripts/dhcp-server-functions.ts',
350
365
  matches: 1,
351
366
  reason:
352
- 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
353
- },
354
- {
355
- module: 'forgejo',
356
- file: 'scripts/on-consumer-removed.ts',
357
- matches: 1,
358
- reason: 'hook-owned-state 5.5 (runner_registrations, Group B3)',
367
+ 'capability provider, no hook entry imports it — ce-mqay (jail does not constrain it; conversion is dedup, not breakage)',
359
368
  },
360
369
  {
361
- module: 'forgejo',
362
- file: 'scripts/setup.ts',
363
- matches: 1,
364
- reason: 'hook-owned-state 5.5 (secret set)',
365
- },
366
- {
367
- module: 'forgejo',
368
- file: 'scripts/source-forge-functions.ts',
369
- matches: 1,
370
- reason:
371
- 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
372
- },
373
- {
374
- module: 'generic-cpanel-hosting-provider',
375
- file: 'scripts/on-consumer-removed.ts',
376
- matches: 1,
377
- reason: 'hook-owned-state 5.5 (publications, Group B4)',
378
- },
379
- {
380
- module: 'generic-cpanel-hosting-provider',
381
- file: 'scripts/publish-functions.ts',
382
- matches: 1,
370
+ module: 'iptables',
371
+ file: 'scripts/firewall-functions.ts',
372
+ matches: 3,
383
373
  reason:
384
- 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
374
+ 'ce-mqay: capability provider, no hook entry imports it. Two config-set writes and one events emit. INVISIBLE to the scan until ce-y0we taught it continuation lines (2026-09-09) — these three were live spawns the green gate could not see',
385
375
  },
386
376
  ];
387
377
 
@@ -393,19 +383,13 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
393
383
  * same guarded rot: when a site converts, the count drops, the debt test goes
394
384
  * red, and the entry must be deleted in the same change.
395
385
  *
396
- * Converted on 2026-09-07 by ce-pvii: knot-unbound-internal/scripts/on-install.ts
397
- * dropped its last swallow (the runCelilo wrapper), so the rule fires on
398
- * exactly these two sites and nothing else in the tree.
386
+ * EMPTY as of ce-y0we (2026-09-09): knot-unbound-internal's on-install dropped
387
+ * its last swallow on 2026-09-07 (ce-pvii), wireguard's health-check converted
388
+ * the same evening (01575380), and celilo-mgmt's on_backup machine-pool read
389
+ * retired the last entry. A NEW swallowed refusal must never land here — it
390
+ * gets fixed, not tracked.
399
391
  */
400
- export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [
401
- {
402
- module: 'celilo-mgmt',
403
- file: 'scripts/on_backup.ts',
404
- matches: 1,
405
- reason:
406
- 'hook-owned-state 5.5 / survey class G: machine list becomes a typed-absent machine-pool read',
407
- },
408
- ];
392
+ export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [];
409
393
 
410
394
  /**
411
395
  * The files inside `@celilo/capabilities` that implement the remote-exec
@@ -85,7 +85,11 @@ describe('recurrence gate: the jailed-CLI-spawn debt list is honest', () => {
85
85
  });
86
86
 
87
87
  test('the debt list is non-trivial (the guard actually reached the entries)', () => {
88
- expect(JAILED_CLI_SPAWN_DEBT.length).toBeGreaterThan(5);
88
+ // Was >5 while the list still carried the hook-owned-state 5.5 batch. The
89
+ // conversions retire entries faster than new debt arrives (ce-y0we), so
90
+ // the floor is now "at least one tracked site" — enough to prove the test
91
+ // reached the list without pinning the retirement schedule.
92
+ expect(JAILED_CLI_SPAWN_DEBT.length).toBeGreaterThan(0);
89
93
  });
90
94
  });
91
95
 
@@ -212,9 +212,9 @@ describe('recurrence gate: a hook does not swallow a refused framework call', ()
212
212
  }
213
213
  });
214
214
 
215
- test('today’s tree: the remaining known sites are flagged, and nothing else is', () => {
215
+ test('today’s tree: no swallowed refusals anywhere, and the scan still reaches every module', () => {
216
216
  // Raw scan, deliberately NOT scanModuleDirectory: the debt list below
217
- // exempts these same files so the merged gate stays green while
217
+ // exempts the same files so the merged gate stays green while
218
218
  // hook-owned-state converts them, and this assertion has to outlive that
219
219
  // exemption to prove the rule still reaches them.
220
220
  const violations = moduleDirs().flatMap((d) =>
@@ -225,13 +225,11 @@ describe('recurrence gate: a hook does not swallow a refused framework call', ()
225
225
  ),
226
226
  );
227
227
  const sites = violations.map((v) => v.file.replace(`${repoRoot()}/`, '')).sort();
228
- // Pinned to the exact sites from e2e-suite-recovery task 4.2, as measured
229
- // on 2026-09-07 at on_backup.ts:52 (a third, on-install.ts:69, converted
230
- // by ce-pvii the same day, and wireguard's health-check.ts converted by
231
- // 01575380 the same evening). A converted site drops out and the debt
232
- // entry must go; a NEW site appearing here is the next instance this scan
233
- // exists to stop.
234
- expect(sites).toEqual(['modules/celilo-mgmt/scripts/on_backup.ts']);
228
+ // Empty since ce-y0we (2026-09-09): on_backup.ts:52 was the last site —
229
+ // its machine-pool read is staged input now — and the two before it
230
+ // converted on 2026-09-07 (ce-pvii and 01575380). A NEW site appearing
231
+ // here is the next instance this scan exists to stop.
232
+ expect(sites, `Module script policy violations:\n${formatViolations(violations)}`).toEqual([]);
235
233
  });
236
234
  });
237
235
 
@@ -255,10 +253,12 @@ describe('recurrence gate: the swallow debt list is honest', () => {
255
253
  }
256
254
  });
257
255
 
258
- test('the debt list is non-trivial (the guard actually reached the entries)', () => {
259
- // The list held three entries until ce-pvii converted the on-install.ts
260
- // entry and 01575380 converted wireguard's health-check (both
261
- // 2026-09-07); one remains, so the guard still proves engagement.
262
- expect(SWALLOWED_REFUSAL_DEBT.length).toBeGreaterThan(0);
256
+ test('the debt list is retired (empty), and this test pins that fact', () => {
257
+ // The list held three entries when the gate landed; ce-pvii and 01575380
258
+ // converted two on 2026-09-07 and ce-y0we retired the last (on_backup's
259
+ // machine-pool read) on 2026-09-09. Kept as an explicit pin so a future
260
+ // entry has to be justified against this sentence: a swallowed refusal is
261
+ // a defect to fix, never debt to track.
262
+ expect(SWALLOWED_REFUSAL_DEBT).toEqual([]);
263
263
  });
264
264
  });
@@ -48,14 +48,14 @@ describe('RegistryClient constructor', () => {
48
48
 
49
49
  afterEach(() => {
50
50
  if (origEnv === undefined) {
51
- process.env.CELILO_REGISTRY_URL = undefined;
51
+ delete process.env.CELILO_REGISTRY_URL;
52
52
  } else {
53
53
  process.env.CELILO_REGISTRY_URL = origEnv;
54
54
  }
55
55
  });
56
56
 
57
57
  test('uses DEFAULT_REGISTRY when no arg and no env var', () => {
58
- process.env.CELILO_REGISTRY_URL = undefined;
58
+ delete process.env.CELILO_REGISTRY_URL;
59
59
  const client = new RegistryClient();
60
60
  expect(client.baseUrl).toBe(DEFAULT_REGISTRY);
61
61
  });
@@ -228,6 +228,71 @@ describe('RegistryClient.publish — Cargo binary protocol', () => {
228
228
  });
229
229
  });
230
230
 
231
+ // ── RegistryClient.publish — failure bodies (ce-sxb3, celilo#1370) ───────────
232
+
233
+ describe('RegistryClient.publish — failure bodies (ce-sxb3)', () => {
234
+ let tmpFile: string;
235
+ let server: ReturnType<typeof Bun.serve> | undefined;
236
+
237
+ beforeEach(async () => {
238
+ tmpFile = `/tmp/test-publish-ce-sxb3-${Date.now()}.netapp`;
239
+ await Bun.write(tmpFile, 'fake netapp content');
240
+ });
241
+
242
+ afterEach(async () => {
243
+ server?.stop(true);
244
+ if (await Bun.file(tmpFile).exists()) await Bun.write(tmpFile, '');
245
+ });
246
+
247
+ function startStubServer(body: string | null, status: number, contentType?: string): string {
248
+ server = Bun.serve({
249
+ port: 0,
250
+ fetch: () =>
251
+ new Response(body, {
252
+ status,
253
+ ...(contentType ? { headers: { 'Content-Type': contentType } } : {}),
254
+ }),
255
+ });
256
+ return `http://localhost:${server.port}`;
257
+ }
258
+
259
+ test('JSON error body reports the refusal detail and the HTTP status', async () => {
260
+ const client = new RegistryClient(
261
+ startStubServer(
262
+ JSON.stringify({ errors: [{ detail: 'version already exists' }] }),
263
+ 409,
264
+ 'application/json',
265
+ ),
266
+ );
267
+ await expect(
268
+ client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
269
+ ).rejects.toThrow('version already exists (HTTP 409)');
270
+ });
271
+
272
+ test('JSON null body names the HTTP status instead of crashing on err.errors', async () => {
273
+ const client = new RegistryClient(startStubServer('null', 500, 'application/json'));
274
+ await expect(
275
+ client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
276
+ ).rejects.toThrow('HTTP 500');
277
+ });
278
+
279
+ test('non-JSON body names the HTTP status', async () => {
280
+ const client = new RegistryClient(
281
+ startStubServer('<html>bad gateway</html>', 502, 'text/html'),
282
+ );
283
+ await expect(
284
+ client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
285
+ ).rejects.toThrow('HTTP 502');
286
+ });
287
+
288
+ test('empty body names the HTTP status', async () => {
289
+ const client = new RegistryClient(startStubServer(null, 403));
290
+ await expect(
291
+ client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
292
+ ).rejects.toThrow('HTTP 403');
293
+ });
294
+ });
295
+
231
296
  // ── module-owner admin endpoints (ce-1ch) ─────────────────────────────────────
232
297
 
233
298
  describe('RegistryClient owner methods', () => {
@@ -305,10 +305,7 @@ export class RegistryClient {
305
305
  });
306
306
 
307
307
  if (!resp.ok) {
308
- const err = (await resp.json().catch(() => ({ errors: [{ detail: resp.statusText }] }))) as {
309
- errors: Array<{ detail: string }>;
310
- };
311
- throw new Error(err.errors?.[0]?.detail ?? `HTTP ${resp.status}`);
308
+ throw new Error(await this.errorDetail(resp));
312
309
  }
313
310
 
314
311
  return resp.json() as Promise<{ ok: boolean; name: string; vers: string }>;
@@ -364,10 +361,13 @@ export class RegistryClient {
364
361
 
365
362
  /** Extract `errors[0].detail` from a Cargo-protocol error body, or fall back to the status. */
366
363
  private async errorDetail(resp: Response): Promise<string> {
367
- const body = (await resp.json().catch(() => ({}))) as {
364
+ // The body can be a JSON error object, JSON null, prose, or nothing. Only
365
+ // the first carries a detail; everything else falls back to the status.
366
+ const body = (await resp.json().catch(() => null)) as {
368
367
  errors?: Array<{ detail: string }>;
369
- };
370
- return body.errors?.[0]?.detail ?? `HTTP ${resp.status}`;
368
+ } | null;
369
+ const detail = body?.errors?.[0]?.detail;
370
+ return detail ? `${detail} (HTTP ${resp.status})` : `HTTP ${resp.status}`;
371
371
  }
372
372
 
373
373
  private async get(url: string): Promise<Response> {