@b00dea/cockpit 0.0.0-stage → 0.1.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 (426) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/LICENSE +21 -0
  3. package/README.md +25 -2
  4. package/bin/cockpit.mjs +33 -0
  5. package/cockpit.schema.json +2644 -0
  6. package/dist/autonomy.d.ts +45 -0
  7. package/dist/autonomy.js +173 -0
  8. package/dist/bounded-read.d.ts +14 -0
  9. package/dist/bounded-read.js +42 -0
  10. package/dist/browser/commands.d.ts +117 -0
  11. package/dist/browser/commands.js +453 -0
  12. package/dist/browser/package-paths.d.ts +1 -0
  13. package/dist/browser/package-paths.js +6 -0
  14. package/dist/browser/proxy.d.ts +22 -0
  15. package/dist/browser/proxy.js +37 -0
  16. package/dist/browser/runtime.d.ts +118 -0
  17. package/dist/browser/runtime.js +186 -0
  18. package/dist/browser/surfaces.d.ts +141 -0
  19. package/dist/browser/surfaces.js +561 -0
  20. package/dist/browser/ui-gate.d.ts +15 -0
  21. package/dist/browser/ui-gate.js +18 -0
  22. package/dist/canonical-json.d.ts +2 -0
  23. package/dist/canonical-json.js +5 -0
  24. package/dist/cli-guard.d.ts +5 -0
  25. package/dist/cli-guard.js +71 -0
  26. package/dist/cli-main.d.ts +176 -0
  27. package/dist/cli-main.js +1584 -0
  28. package/dist/cli.d.ts +3 -0
  29. package/dist/cli.js +16 -0
  30. package/dist/command-table.d.ts +121 -0
  31. package/dist/command-table.js +664 -0
  32. package/dist/config/load.d.ts +183 -0
  33. package/dist/config/load.js +367 -0
  34. package/dist/config/sibling-toml.d.ts +48 -0
  35. package/dist/config/sibling-toml.js +160 -0
  36. package/dist/config/surface-tools-toml.d.ts +9 -0
  37. package/dist/config/surface-tools-toml.js +37 -0
  38. package/dist/config/unconfirmed-toml.d.ts +9 -0
  39. package/dist/config/unconfirmed-toml.js +29 -0
  40. package/dist/config/verified-write.d.ts +43 -0
  41. package/dist/config/verified-write.js +89 -0
  42. package/dist/diagnostics.d.ts +26 -0
  43. package/dist/diagnostics.js +344 -0
  44. package/dist/docs/check.d.ts +16 -0
  45. package/dist/docs/check.js +197 -0
  46. package/dist/docs/decision-record.d.ts +34 -0
  47. package/dist/docs/decision-record.js +135 -0
  48. package/dist/docs/decisions.d.ts +16 -0
  49. package/dist/docs/decisions.js +103 -0
  50. package/dist/docs/markdown.d.ts +36 -0
  51. package/dist/docs/markdown.js +88 -0
  52. package/dist/docs/references.d.ts +32 -0
  53. package/dist/docs/references.js +148 -0
  54. package/dist/emit.d.ts +22 -0
  55. package/dist/emit.js +43 -0
  56. package/dist/entrypoint-kinds.d.ts +3 -0
  57. package/dist/entrypoint-kinds.js +11 -0
  58. package/dist/environment/application/confirm.d.ts +35 -0
  59. package/dist/environment/application/confirm.js +122 -0
  60. package/dist/environment/application/destructive.d.ts +6 -0
  61. package/dist/environment/application/destructive.js +429 -0
  62. package/dist/environment/application/doctor.d.ts +94 -0
  63. package/dist/environment/application/doctor.js +247 -0
  64. package/dist/environment/application/guard.d.ts +49 -0
  65. package/dist/environment/application/guard.js +677 -0
  66. package/dist/environment/application/health.d.ts +143 -0
  67. package/dist/environment/application/health.js +385 -0
  68. package/dist/environment/application/lifecycle-context.d.ts +235 -0
  69. package/dist/environment/application/lifecycle-context.js +154 -0
  70. package/dist/environment/application/lifecycle-state.d.ts +25 -0
  71. package/dist/environment/application/lifecycle-state.js +202 -0
  72. package/dist/environment/application/lifecycle.d.ts +18 -0
  73. package/dist/environment/application/lifecycle.js +737 -0
  74. package/dist/environment/application/logs.d.ts +45 -0
  75. package/dist/environment/application/logs.js +127 -0
  76. package/dist/environment/application/services.d.ts +60 -0
  77. package/dist/environment/application/services.js +89 -0
  78. package/dist/environment/application/setup.d.ts +129 -0
  79. package/dist/environment/application/setup.js +741 -0
  80. package/dist/environment/application/shared.d.ts +11 -0
  81. package/dist/environment/application/shared.js +61 -0
  82. package/dist/environment/application/siblings.d.ts +43 -0
  83. package/dist/environment/application/siblings.js +269 -0
  84. package/dist/environment/claims.d.ts +389 -0
  85. package/dist/environment/claims.js +1701 -0
  86. package/dist/environment/codex-hook-trust.d.ts +11 -0
  87. package/dist/environment/codex-hook-trust.js +185 -0
  88. package/dist/environment/compose-environment.d.ts +1 -0
  89. package/dist/environment/compose-environment.js +6 -0
  90. package/dist/environment/compose-ownership.d.ts +21 -0
  91. package/dist/environment/compose-ownership.js +117 -0
  92. package/dist/environment/compose-topology.d.ts +125 -0
  93. package/dist/environment/compose-topology.js +490 -0
  94. package/dist/environment/contract.d.ts +522 -0
  95. package/dist/environment/contract.js +794 -0
  96. package/dist/environment/declaration.d.ts +14 -0
  97. package/dist/environment/declaration.js +39 -0
  98. package/dist/environment/destructive-composition.d.ts +29 -0
  99. package/dist/environment/destructive-composition.js +333 -0
  100. package/dist/environment/docker-daemon.d.ts +30 -0
  101. package/dist/environment/docker-daemon.js +78 -0
  102. package/dist/environment/docker-endpoint.d.ts +10 -0
  103. package/dist/environment/docker-endpoint.js +19 -0
  104. package/dist/environment/env-schema-observation.d.ts +46 -0
  105. package/dist/environment/env-schema-observation.js +241 -0
  106. package/dist/environment/evidence.d.ts +136 -0
  107. package/dist/environment/evidence.js +437 -0
  108. package/dist/environment/executable.d.ts +38 -0
  109. package/dist/environment/executable.js +190 -0
  110. package/dist/environment/generated-application/authorities.d.ts +5 -0
  111. package/dist/environment/generated-application/authorities.js +61 -0
  112. package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
  113. package/dist/environment/generated-application/bootstrap-skill.js +328 -0
  114. package/dist/environment/generated-application/check.d.ts +13 -0
  115. package/dist/environment/generated-application/check.js +191 -0
  116. package/dist/environment/generated-application/custom-surface.d.ts +26 -0
  117. package/dist/environment/generated-application/custom-surface.js +289 -0
  118. package/dist/environment/generated-application/prepare.d.ts +29 -0
  119. package/dist/environment/generated-application/prepare.js +278 -0
  120. package/dist/environment/generated-application/publication.d.ts +33 -0
  121. package/dist/environment/generated-application/publication.js +547 -0
  122. package/dist/environment/generated-application/shared.d.ts +492 -0
  123. package/dist/environment/generated-application/shared.js +354 -0
  124. package/dist/environment/generated-application/surface-observation.d.ts +4 -0
  125. package/dist/environment/generated-application/surface-observation.js +69 -0
  126. package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
  127. package/dist/environment/generated-application/surface-outputs.js +400 -0
  128. package/dist/environment/generated-application/surfaces.d.ts +3 -0
  129. package/dist/environment/generated-application/surfaces.js +704 -0
  130. package/dist/environment/generated-application/sync.d.ts +6 -0
  131. package/dist/environment/generated-application/sync.js +124 -0
  132. package/dist/environment/git-observation.d.ts +202 -0
  133. package/dist/environment/git-observation.js +553 -0
  134. package/dist/environment/health-composition.d.ts +12 -0
  135. package/dist/environment/health-composition.js +645 -0
  136. package/dist/environment/host-observation.d.ts +56 -0
  137. package/dist/environment/host-observation.js +52 -0
  138. package/dist/environment/identity.d.ts +48 -0
  139. package/dist/environment/identity.js +85 -0
  140. package/dist/environment/init-application.d.ts +333 -0
  141. package/dist/environment/init-application.js +726 -0
  142. package/dist/environment/install-policy-observation.d.ts +71 -0
  143. package/dist/environment/install-policy-observation.js +284 -0
  144. package/dist/environment/install-policy.d.ts +48 -0
  145. package/dist/environment/install-policy.js +96 -0
  146. package/dist/environment/instance.d.ts +33 -0
  147. package/dist/environment/instance.js +122 -0
  148. package/dist/environment/instruction-pointer.d.ts +19 -0
  149. package/dist/environment/instruction-pointer.js +144 -0
  150. package/dist/environment/lifecycle-composition.d.ts +31 -0
  151. package/dist/environment/lifecycle-composition.js +742 -0
  152. package/dist/environment/lifecycle-stage.d.ts +120 -0
  153. package/dist/environment/lifecycle-stage.js +408 -0
  154. package/dist/environment/lifecycle.d.ts +75 -0
  155. package/dist/environment/lifecycle.js +354 -0
  156. package/dist/environment/logs.d.ts +132 -0
  157. package/dist/environment/logs.js +519 -0
  158. package/dist/environment/map-limit.d.ts +2 -0
  159. package/dist/environment/map-limit.js +13 -0
  160. package/dist/environment/mcp-observation.d.ts +9 -0
  161. package/dist/environment/mcp-observation.js +46 -0
  162. package/dist/environment/observations.d.ts +46 -0
  163. package/dist/environment/observations.js +346 -0
  164. package/dist/environment/operator-application.d.ts +79 -0
  165. package/dist/environment/operator-application.js +197 -0
  166. package/dist/environment/ports.d.ts +182 -0
  167. package/dist/environment/ports.js +1205 -0
  168. package/dist/environment/posture-observation.d.ts +68 -0
  169. package/dist/environment/posture-observation.js +253 -0
  170. package/dist/environment/presence.d.ts +4 -0
  171. package/dist/environment/presence.js +26 -0
  172. package/dist/environment/read-composition.d.ts +32 -0
  173. package/dist/environment/read-composition.js +397 -0
  174. package/dist/environment/readiness-composition.d.ts +57 -0
  175. package/dist/environment/readiness-composition.js +151 -0
  176. package/dist/environment/reclaim.d.ts +370 -0
  177. package/dist/environment/reclaim.js +1940 -0
  178. package/dist/environment/repository-identity.d.ts +13 -0
  179. package/dist/environment/repository-identity.js +108 -0
  180. package/dist/environment/runtime-measurement-record.d.ts +38 -0
  181. package/dist/environment/runtime-measurement-record.js +111 -0
  182. package/dist/environment/runtime-measurement.d.ts +27 -0
  183. package/dist/environment/runtime-measurement.js +224 -0
  184. package/dist/environment/runtime-probe-residue.d.ts +43 -0
  185. package/dist/environment/runtime-probe-residue.js +148 -0
  186. package/dist/environment/runtime-probe.d.ts +72 -0
  187. package/dist/environment/runtime-probe.js +725 -0
  188. package/dist/environment/runtime.d.ts +551 -0
  189. package/dist/environment/runtime.js +2405 -0
  190. package/dist/environment/seed.d.ts +16 -0
  191. package/dist/environment/seed.js +43 -0
  192. package/dist/environment/selftest-application.d.ts +86 -0
  193. package/dist/environment/selftest-application.js +713 -0
  194. package/dist/environment/sensitive.d.ts +3 -0
  195. package/dist/environment/sensitive.js +48 -0
  196. package/dist/environment/served-checkout-grants.d.ts +53 -0
  197. package/dist/environment/served-checkout-grants.js +182 -0
  198. package/dist/environment/setup-observation.d.ts +62 -0
  199. package/dist/environment/setup-observation.js +1473 -0
  200. package/dist/environment/setup.d.ts +43 -0
  201. package/dist/environment/setup.js +176 -0
  202. package/dist/environment/sibling-checkouts.d.ts +119 -0
  203. package/dist/environment/sibling-checkouts.js +254 -0
  204. package/dist/environment/sibling-instructions.d.ts +33 -0
  205. package/dist/environment/sibling-instructions.js +224 -0
  206. package/dist/environment/sibling-observation.d.ts +27 -0
  207. package/dist/environment/sibling-observation.js +45 -0
  208. package/dist/environment/sibling-pull.d.ts +23 -0
  209. package/dist/environment/sibling-pull.js +95 -0
  210. package/dist/environment/sibling-registration.d.ts +45 -0
  211. package/dist/environment/sibling-registration.js +538 -0
  212. package/dist/environment/stack-inputs.d.ts +18 -0
  213. package/dist/environment/stack-inputs.js +210 -0
  214. package/dist/environment/stack-inventory-prune.d.ts +31 -0
  215. package/dist/environment/stack-inventory-prune.js +129 -0
  216. package/dist/environment/stack-inventory-report.d.ts +44 -0
  217. package/dist/environment/stack-inventory-report.js +78 -0
  218. package/dist/environment/stack-inventory.d.ts +94 -0
  219. package/dist/environment/stack-inventory.js +418 -0
  220. package/dist/environment/state.d.ts +410 -0
  221. package/dist/environment/state.js +2153 -0
  222. package/dist/environment/templates.d.ts +140 -0
  223. package/dist/environment/templates.js +685 -0
  224. package/dist/environment/tool-registration.d.ts +9 -0
  225. package/dist/environment/tool-registration.js +92 -0
  226. package/dist/environment/upgrade-application.d.ts +19 -0
  227. package/dist/environment/upgrade-application.js +175 -0
  228. package/dist/environment-exec/execute.d.ts +105 -0
  229. package/dist/environment-exec/execute.js +1179 -0
  230. package/dist/environment-exec/libexec-program.d.ts +6 -0
  231. package/dist/environment-exec/libexec-program.js +22 -0
  232. package/dist/environment-exec/verified-digest.d.ts +4 -0
  233. package/dist/environment-exec/verified-digest.js +16 -0
  234. package/dist/exact-keys.d.ts +7 -0
  235. package/dist/exact-keys.js +15 -0
  236. package/dist/fix-catalogue.d.ts +32 -0
  237. package/dist/fix-catalogue.js +60 -0
  238. package/dist/freshness.d.ts +29 -0
  239. package/dist/freshness.js +75 -0
  240. package/dist/generated-environment/adopt.d.ts +16 -0
  241. package/dist/generated-environment/adopt.js +271 -0
  242. package/dist/generated-environment/command.d.ts +47 -0
  243. package/dist/generated-environment/command.js +75 -0
  244. package/dist/generated-environment/transaction.d.ts +186 -0
  245. package/dist/generated-environment/transaction.js +1770 -0
  246. package/dist/grading/evaluate.d.ts +71 -0
  247. package/dist/grading/evaluate.js +403 -0
  248. package/dist/grading/grade.d.ts +33 -0
  249. package/dist/grading/grade.js +57 -0
  250. package/dist/guards/answer-confirmation.d.ts +41 -0
  251. package/dist/guards/answer-confirmation.js +109 -0
  252. package/dist/guards/evaluator.d.ts +130 -0
  253. package/dist/guards/evaluator.js +1258 -0
  254. package/dist/guards/harness-adapter.d.ts +17 -0
  255. package/dist/guards/harness-adapter.js +57 -0
  256. package/dist/guards/host.d.ts +8 -0
  257. package/dist/guards/host.js +10 -0
  258. package/dist/guards/invocation-parser.d.ts +66 -0
  259. package/dist/guards/invocation-parser.js +595 -0
  260. package/dist/guards/local-identity.d.ts +44 -0
  261. package/dist/guards/local-identity.js +129 -0
  262. package/dist/guards/policy-change.d.ts +43 -0
  263. package/dist/guards/policy-change.js +159 -0
  264. package/dist/guards/policy-pin.d.ts +13 -0
  265. package/dist/guards/policy-pin.js +177 -0
  266. package/dist/guards/protected-locations.d.ts +62 -0
  267. package/dist/guards/protected-locations.js +461 -0
  268. package/dist/guards/read-only.d.ts +37 -0
  269. package/dist/guards/read-only.js +315 -0
  270. package/dist/init/command.d.ts +41 -0
  271. package/dist/init/command.js +330 -0
  272. package/dist/init/derive.d.ts +53 -0
  273. package/dist/init/derive.js +819 -0
  274. package/dist/init/draft.d.ts +140 -0
  275. package/dist/init/draft.js +1228 -0
  276. package/dist/init/render.d.ts +47 -0
  277. package/dist/init/render.js +104 -0
  278. package/dist/init/scaffold.d.ts +53 -0
  279. package/dist/init/scaffold.js +121 -0
  280. package/dist/invocation.d.ts +13 -0
  281. package/dist/invocation.js +20 -0
  282. package/dist/package-metadata.d.ts +9 -0
  283. package/dist/package-metadata.js +19 -0
  284. package/dist/readiness.d.ts +53 -0
  285. package/dist/readiness.js +88 -0
  286. package/dist/references/named.d.ts +24 -0
  287. package/dist/references/named.js +27 -0
  288. package/dist/refusal.d.ts +15 -0
  289. package/dist/refusal.js +7 -0
  290. package/dist/registry-derivation.d.ts +31 -0
  291. package/dist/registry-derivation.js +32 -0
  292. package/dist/registry-resolve/command.d.ts +47 -0
  293. package/dist/registry-resolve/command.js +226 -0
  294. package/dist/registry-resolve/registry.d.ts +68 -0
  295. package/dist/registry-resolve/registry.js +292 -0
  296. package/dist/registry-resolve/render-shell.d.ts +18 -0
  297. package/dist/registry-resolve/render-shell.js +36 -0
  298. package/dist/repair.d.ts +29 -0
  299. package/dist/repair.js +8 -0
  300. package/dist/repository-path.d.ts +9 -0
  301. package/dist/repository-path.js +16 -0
  302. package/dist/schema-json.d.ts +1 -0
  303. package/dist/schema-json.js +177 -0
  304. package/dist/schema.d.ts +1289 -0
  305. package/dist/schema.js +985 -0
  306. package/dist/setup/command.d.ts +4 -0
  307. package/dist/setup/command.js +4 -0
  308. package/dist/sha256.d.ts +1 -0
  309. package/dist/sha256.js +4 -0
  310. package/dist/surfaces/adoption.d.ts +41 -0
  311. package/dist/surfaces/adoption.js +217 -0
  312. package/dist/surfaces/consumer-instructions.d.ts +20 -0
  313. package/dist/surfaces/consumer-instructions.js +43 -0
  314. package/dist/surfaces/contract.d.ts +42 -0
  315. package/dist/surfaces/contract.js +169 -0
  316. package/dist/surfaces/core-declarations.d.ts +24 -0
  317. package/dist/surfaces/core-declarations.js +79 -0
  318. package/dist/surfaces/development-markers.d.ts +1 -0
  319. package/dist/surfaces/development-markers.js +18 -0
  320. package/dist/surfaces/generator-adapter.d.ts +71 -0
  321. package/dist/surfaces/generator-adapter.js +809 -0
  322. package/dist/surfaces/harness-registry.d.ts +260 -0
  323. package/dist/surfaces/harness-registry.js +146 -0
  324. package/dist/surfaces/mcp.d.ts +145 -0
  325. package/dist/surfaces/mcp.js +349 -0
  326. package/dist/surfaces/merge.d.ts +30 -0
  327. package/dist/surfaces/merge.js +105 -0
  328. package/dist/surfaces/ownership.d.ts +190 -0
  329. package/dist/surfaces/ownership.js +795 -0
  330. package/dist/surfaces/projection.d.ts +136 -0
  331. package/dist/surfaces/projection.js +716 -0
  332. package/dist/surfaces/render.d.ts +79 -0
  333. package/dist/surfaces/render.js +636 -0
  334. package/dist/surfaces/restrictions.d.ts +130 -0
  335. package/dist/surfaces/restrictions.js +447 -0
  336. package/dist/surfaces/selftest.d.ts +162 -0
  337. package/dist/surfaces/selftest.js +393 -0
  338. package/dist/surfaces/skill-contract.d.ts +85 -0
  339. package/dist/surfaces/skill-contract.js +449 -0
  340. package/dist/surfaces/sync.d.ts +57 -0
  341. package/dist/surfaces/sync.js +164 -0
  342. package/dist/team-metadata.d.ts +41 -0
  343. package/dist/team-metadata.js +513 -0
  344. package/dist/testing/gates.d.ts +91 -0
  345. package/dist/testing/gates.js +182 -0
  346. package/dist/testing/hooks.d.ts +59 -0
  347. package/dist/testing/hooks.js +139 -0
  348. package/dist/testing/test-protection.d.ts +34 -0
  349. package/dist/testing/test-protection.js +137 -0
  350. package/dist/testing/verdict.d.ts +29 -0
  351. package/dist/testing/verdict.js +62 -0
  352. package/dist/testing/weakening.d.ts +15 -0
  353. package/dist/testing/weakening.js +30 -0
  354. package/dist/work-orders/application-closure.d.ts +7 -0
  355. package/dist/work-orders/application-closure.js +76 -0
  356. package/dist/work-orders/application-context.d.ts +162 -0
  357. package/dist/work-orders/application-context.js +137 -0
  358. package/dist/work-orders/application-decisions.d.ts +27 -0
  359. package/dist/work-orders/application-decisions.js +121 -0
  360. package/dist/work-orders/application-grading.d.ts +11 -0
  361. package/dist/work-orders/application-grading.js +212 -0
  362. package/dist/work-orders/application.d.ts +6 -0
  363. package/dist/work-orders/application.js +223 -0
  364. package/dist/work-orders/composition.d.ts +101 -0
  365. package/dist/work-orders/composition.js +1202 -0
  366. package/dist/work-orders/contract.d.ts +77 -0
  367. package/dist/work-orders/contract.js +140 -0
  368. package/dist/work-orders/decisions.d.ts +23 -0
  369. package/dist/work-orders/decisions.js +55 -0
  370. package/dist/work-orders/store.d.ts +207 -0
  371. package/dist/work-orders/store.js +291 -0
  372. package/docs/README.md +68 -0
  373. package/docs/architecture.md +136 -0
  374. package/docs/capability-matrix.md +58 -0
  375. package/docs/cli-reference.md +807 -0
  376. package/docs/command-list.md +62 -0
  377. package/docs/compose-patterns.md +99 -0
  378. package/docs/concepts.md +173 -0
  379. package/docs/configuration-essentials.md +177 -0
  380. package/docs/configuration-reference.md +636 -0
  381. package/docs/docs-check.md +76 -0
  382. package/docs/environment.md +948 -0
  383. package/docs/extending.md +43 -0
  384. package/docs/faq.md +113 -0
  385. package/docs/getting-started.md +692 -0
  386. package/docs/harness-surfaces.md +500 -0
  387. package/docs/joining-a-team.md +76 -0
  388. package/docs/release-policy.md +49 -0
  389. package/docs/safety-and-autonomy.md +463 -0
  390. package/docs/troubleshooting.md +382 -0
  391. package/docs/tutorial.md +171 -0
  392. package/docs/work-and-browser.md +420 -0
  393. package/libexec/browser-operation.cjs +270 -0
  394. package/libexec/fence-marker.cjs +101 -0
  395. package/libexec/fence-marker.d.cts +24 -0
  396. package/libexec/fenced-group-runner.cjs +77 -0
  397. package/libexec/fenced-supervisor.cjs +137 -0
  398. package/libexec/generated-filesystem.cjs +731 -0
  399. package/libexec/install-filesystem.cjs +225 -0
  400. package/libexec/rulesync-generator.NOTICE.md +1112 -0
  401. package/libexec/rulesync-generator.cjs +743 -0
  402. package/libexec/surface-generator-worker.cjs +266 -0
  403. package/libexec/surface-output-reader.cjs +99 -0
  404. package/libexec/surface-scratch-cleanup.cjs +14 -0
  405. package/libexec/validate-harness-evidence.d.mts +183 -0
  406. package/libexec/validate-harness-evidence.mjs +1312 -0
  407. package/package.json +74 -4
  408. package/surfaces/README.md +33 -0
  409. package/surfaces/agents/evaluator.agent.md +76 -0
  410. package/surfaces/guards/bash-guard.sh +48 -0
  411. package/surfaces/guards/claude.adapter.json +25 -0
  412. package/surfaces/guards/codex.adapter.json +24 -0
  413. package/surfaces/guards/opencode.adapter.json +25 -0
  414. package/surfaces/references/browser.md +63 -0
  415. package/surfaces/references/test-data-and-personas.md +84 -0
  416. package/surfaces/references/testing-tiers.md +11 -0
  417. package/surfaces/skills/cockpit-browser.skill.md +59 -0
  418. package/surfaces/skills/cockpit-explain.skill.md +32 -0
  419. package/surfaces/skills/cockpit-fix.skill.md +37 -0
  420. package/surfaces/skills/cockpit-setup.skill.md +39 -0
  421. package/surfaces/skills/cockpit-work.skill.md +34 -0
  422. package/surfaces/skills/collection.json +5 -0
  423. package/surfaces/skills/procedures/explain.md +66 -0
  424. package/surfaces/skills/procedures/fix.md +95 -0
  425. package/surfaces/skills/procedures/setup.md +452 -0
  426. package/surfaces/skills/procedures/work.md +134 -0
@@ -0,0 +1,1770 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { sha256Hex } from '../sha256.js';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, mkdtemp, open, realpath, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { isProxy } from 'node:util/types';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import { authenticatedExecutionOutcome } from '../environment-exec/execute.js';
10
+ import { libexecProgram } from '../environment-exec/libexec-program.js';
11
+ import { fileFingerprint, recordVerifiedDigest, verifiedDigest } from '../environment-exec/verified-digest.js';
12
+ import { errorCause, snapshotGeneratedJson, RefusalError, tryParseJson, hasErrorCode } from '../environment/contract.js';
13
+ import { readState } from '../environment/state.js';
14
+ import { parseOwnershipManifest, projectGeneratedPathFacts, classifyGeneratedPaths, removeSharedOwnership, renderSharedPostimage } from '../surfaces/ownership.js';
15
+ import { reservedPathSegment } from '../surfaces/contract.js';
16
+ import { isInspectedBootstrapOutputs } from '../surfaces/skill-contract.js';
17
+ import { exactKeys } from '../exact-keys.js';
18
+ import { refusalOutcome as refuse } from '../refusal.js';
19
+ const SHA256 = /^[a-f0-9]{64}$/;
20
+ const OWNERSHIP_PATH = '.cockpit/generated-environment-ownership.json';
21
+ const BOOTSTRAP_CONFIGURATION_PATH = 'cockpit.toml';
22
+ const MAX_OWNERSHIP_BYTES = 1_048_576;
23
+ const MAX_OWNED_FILES = 256;
24
+ const MAX_EXECUTABLE_BYTES = 536_870_912;
25
+ const MAX_GENERATED_BYTES = 16_777_216;
26
+ const MAX_HELPER_FRAME_BYTES = 65_536;
27
+ const HELPER_TIMEOUT_MS = 10_000;
28
+ const MAX_AUTHENTICATED_READ_BATCH = 64;
29
+ const MAX_AUTHENTICATED_READ_BATCH_BYTES = 67_108_864;
30
+ const MAX_AUTHENTICATED_READ_BATCH_RECEIPT_BYTES = MAX_HELPER_FRAME_BYTES * (MAX_AUTHENTICATED_READ_BATCH + 1);
31
+ const MAX_PUBLICATION_BATCH_GROUPS = 8;
32
+ const TRANSACTION_TIMEOUT_MS = 180_000;
33
+ // Reserve the final deadline slots so forward work cannot consume rollback's budget.
34
+ const PUBLICATION_HELPER_SLOT_MS = 500;
35
+ const PUBLICATION_HELPER_CALLS_PER_GROUP = 6;
36
+ const compareCodeUnits = (left, right) => left < right ? -1 : left > right ? 1 : 0;
37
+ function ownRecord(value) {
38
+ try {
39
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || isProxy(value)
40
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
41
+ return undefined;
42
+ const descriptors = Object.getOwnPropertyDescriptors(value);
43
+ if (Object.getOwnPropertySymbols(value).length > 0
44
+ || Object.values(descriptors).some((descriptor) => descriptor.get || descriptor.set || !descriptor.enumerable))
45
+ return undefined;
46
+ return Object.fromEntries(Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]));
47
+ }
48
+ catch {
49
+ // a guard over caller-supplied values; a throwing accessor or proxy trap is malformed input the caller refuses.
50
+ return undefined;
51
+ }
52
+ }
53
+ function confined(root, path) {
54
+ if (typeof path !== 'string' || path.length === 0 || path.includes('\0') || isAbsolute(path))
55
+ return undefined;
56
+ const target = resolve(root, path);
57
+ const offset = relative(root, target);
58
+ return offset !== '' && offset !== '..' && !offset.startsWith(`..${sep}`) && !isAbsolute(offset) ? target : undefined;
59
+ }
60
+ // Every anchored read and publication step runs in libexec/generated-filesystem.cjs, which
61
+ // explains why that work needs its own process. The helper enforces these bounds; each request
62
+ // carries them so they keep one owner here.
63
+ const HELPER_PROGRAM = 'generated-filesystem.cjs';
64
+ const HELPER_LIMITS = Object.freeze({
65
+ fileBytes: MAX_GENERATED_BYTES,
66
+ frameBytes: MAX_HELPER_FRAME_BYTES,
67
+ readBatchEntries: MAX_AUTHENTICATED_READ_BATCH,
68
+ readBatchBytes: MAX_AUTHENTICATED_READ_BATCH_BYTES,
69
+ readBatchReceiptBytes: MAX_AUTHENTICATED_READ_BATCH_RECEIPT_BYTES,
70
+ publicationGroups: MAX_PUBLICATION_BATCH_GROUPS,
71
+ });
72
+ function helperFrame(request) {
73
+ return JSON.stringify({ ...request, limits: HELPER_LIMITS });
74
+ }
75
+ function helperFrameBytes(request) {
76
+ return Buffer.byteLength(helperFrame(request));
77
+ }
78
+ async function digestFile(path, maxBytes) {
79
+ let handle;
80
+ try {
81
+ const before = await lstat(path);
82
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes || (before.mode & 0o111) === 0)
83
+ return undefined;
84
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
85
+ const opened = await handle.stat();
86
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > maxBytes)
87
+ return undefined;
88
+ const fingerprint = fileFingerprint(path, opened);
89
+ const known = verifiedDigest(fingerprint);
90
+ if (known)
91
+ return known;
92
+ const hash = createHash('sha256');
93
+ const chunk = Buffer.allocUnsafe(Math.min(maxBytes + 1, 1_048_576));
94
+ let total = 0;
95
+ while (true) {
96
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, maxBytes - total + 1), null);
97
+ if (bytesRead === 0)
98
+ break;
99
+ total += bytesRead;
100
+ if (total > maxBytes)
101
+ return undefined;
102
+ hash.update(chunk.subarray(0, bytesRead));
103
+ }
104
+ const after = await handle.stat();
105
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs)
106
+ return undefined;
107
+ const digest = hash.digest('hex');
108
+ if (fileFingerprint(path, after) === fingerprint)
109
+ recordVerifiedDigest(fingerprint, digest);
110
+ return digest;
111
+ }
112
+ catch {
113
+ // an unavailable authority is refused by the caller.
114
+ return undefined;
115
+ }
116
+ finally {
117
+ try {
118
+ await handle?.close();
119
+ }
120
+ catch { /* Unavailable authority remains a refusal. */ }
121
+ }
122
+ }
123
+ export async function nodeAuthority() {
124
+ try {
125
+ const canonicalPath = await realpath(process.execPath);
126
+ const digest = await digestFile(canonicalPath, MAX_EXECUTABLE_BYTES);
127
+ return digest ? {
128
+ canonicalPath,
129
+ invocationPath: canonicalPath,
130
+ sha256: digest,
131
+ executionMode: 'verified-path',
132
+ } : undefined;
133
+ }
134
+ catch {
135
+ // an unavailable authority is refused by the caller.
136
+ return undefined;
137
+ }
138
+ }
139
+ async function publicationAnchor(root, parent) {
140
+ let path = root;
141
+ const segments = parent === '.' ? [] : parent.split('/');
142
+ const chain = [];
143
+ const anchored = async (missing) => {
144
+ const status = await lstat(path);
145
+ return status.isDirectory() && !status.isSymbolicLink()
146
+ ? { path, identity: { dev: status.dev, ino: status.ino }, chain, segments: missing }
147
+ : undefined;
148
+ };
149
+ for (const [index, segment] of segments.entries()) {
150
+ const candidate = join(path, segment);
151
+ try {
152
+ const status = await lstat(candidate);
153
+ if (!status.isDirectory() || status.isSymbolicLink())
154
+ return undefined;
155
+ path = candidate;
156
+ chain.push({ name: segment, dev: status.dev, ino: status.ino });
157
+ }
158
+ catch (error) {
159
+ if (!hasErrorCode(error, 'ENOENT'))
160
+ return undefined;
161
+ return anchored(segments.slice(index));
162
+ }
163
+ }
164
+ return anchored([]);
165
+ }
166
+ async function nestedPublicationAuthority(groupPath, groupExists, segments) {
167
+ if (!groupExists)
168
+ return { chain: [], segments };
169
+ const chain = [];
170
+ let current = groupPath;
171
+ for (const [index, name] of segments.entries()) {
172
+ if (!safePublicationSegment(name))
173
+ return undefined;
174
+ current = resolve(current, name);
175
+ try {
176
+ const status = await lstat(current);
177
+ if (!status.isDirectory() || status.isSymbolicLink())
178
+ return undefined;
179
+ chain.push({ name, dev: status.dev, ino: status.ino });
180
+ }
181
+ catch (error) {
182
+ if (!hasErrorCode(error, 'ENOENT'))
183
+ return undefined;
184
+ return { chain, segments: segments.slice(index) };
185
+ }
186
+ }
187
+ return { chain, segments: [] };
188
+ }
189
+ function helperStep(node, cwd, parent, request, inputs, deadline, stdoutLimitBytes = MAX_HELPER_FRAME_BYTES) {
190
+ const remaining = deadline - Date.now();
191
+ if (remaining <= 0)
192
+ return undefined;
193
+ const requestBytes = helperFrame(request);
194
+ if (Buffer.byteLength(requestBytes) > MAX_HELPER_FRAME_BYTES)
195
+ return undefined;
196
+ // An unauthenticated program is one more unavailable step: the caller refuses or compensates,
197
+ // and a transaction that has started keeps its recovery inputs.
198
+ let program;
199
+ try {
200
+ program = libexecProgram(HELPER_PROGRAM);
201
+ }
202
+ catch { /* an unauthenticated helper program is unavailable; the caller refuses or compensates. */
203
+ return undefined;
204
+ }
205
+ return {
206
+ id: `generated-filesystem-${request.action}-${request.nonce}`,
207
+ argv: [node.canonicalPath, '--', program.path, requestBytes, ...inputs.map(({ path }) => path)],
208
+ argv0: node.invocationPath,
209
+ cwd,
210
+ env: {},
211
+ timeoutMs: Math.min(HELPER_TIMEOUT_MS, remaining),
212
+ spawnNotAfter: new Date(deadline).toISOString(),
213
+ stdoutLimitBytes,
214
+ stderrLimitBytes: 16_384,
215
+ paths: [cwd],
216
+ ports: [],
217
+ labels: { action: request.action, parent },
218
+ expected: [],
219
+ consumedFiles: [
220
+ { path: node.canonicalPath, sha256: node.sha256, maxBytes: MAX_EXECUTABLE_BYTES, argvIndexes: [0], executable: true, executionMode: node.executionMode },
221
+ { path: program.path, sha256: program.sha256, maxBytes: program.maxBytes, argvIndexes: [2], executable: false },
222
+ ...inputs.map(({ path, sha256: digest }, index) => ({ path, sha256: digest, maxBytes: MAX_GENERATED_BYTES, argvIndexes: [4 + index], executable: false })),
223
+ ],
224
+ filesystemChecks: [{ path: cwd, expected: 'directory' }],
225
+ compensation: [],
226
+ };
227
+ }
228
+ function helperRecord(value, keys) {
229
+ const record = ownRecord(value);
230
+ return record && exactKeys(record, keys) ? record : undefined;
231
+ }
232
+ function directoryIdentity(value) {
233
+ const record = helperRecord(value, ['dev', 'ino']);
234
+ return record && Number.isSafeInteger(record.dev) && Number.isSafeInteger(record.ino)
235
+ ? { dev: record.dev, ino: record.ino } : undefined;
236
+ }
237
+ export async function readAnchoredFile(root, path, maxBytes, node, adapters, deadline) {
238
+ const action = 'read-file';
239
+ const target = confined(root, path);
240
+ if (!target || constants.O_NOFOLLOW === undefined || !Number.isSafeInteger(maxBytes)
241
+ || maxBytes < 1 || maxBytes > MAX_GENERATED_BYTES)
242
+ return { kind: 'invalid' };
243
+ const parts = relative(root, target).split(sep);
244
+ if (parts.length < 1 || parts.some((part) => part === '' || part === '.' || part === '..'))
245
+ return { kind: 'invalid' };
246
+ let rootStatus;
247
+ try {
248
+ rootStatus = await lstat(root);
249
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink())
250
+ return { kind: 'invalid' };
251
+ }
252
+ catch {
253
+ // an unobservable root is an invalid anchored read the caller refuses.
254
+ return { kind: 'invalid' };
255
+ }
256
+ let directory;
257
+ try {
258
+ directory = await mkdtemp(join(tmpdir(), 'cockpit-generated-read-'));
259
+ }
260
+ catch {
261
+ // an unobservable root is an invalid anchored read the caller refuses.
262
+ return { kind: 'invalid' };
263
+ }
264
+ try {
265
+ const nonce = randomBytes(16).toString('hex');
266
+ const output = join(directory, 'source');
267
+ const request = {
268
+ version: 1, nonce, action, maxBytes,
269
+ identity: { dev: rootStatus.dev, ino: rootStatus.ino },
270
+ segments: parts.slice(0, -1), name: parts.at(-1), output,
271
+ };
272
+ const helperDeadline = Math.min(Date.now() + HELPER_TIMEOUT_MS, deadline ?? Number.MAX_SAFE_INTEGER);
273
+ const rawReceipt = await runHelper(adapters, helperStep(node, root, '.', request, [], helperDeadline));
274
+ const missing = helperRecord(rawReceipt, ['version', 'nonce', 'action', 'kind']);
275
+ if (missing?.version === 1 && missing.nonce === nonce && missing.action === action && missing.kind === 'missing') {
276
+ return { kind: 'missing' };
277
+ }
278
+ const receipt = helperRecord(rawReceipt, ['version', 'nonce', 'action', 'kind', 'identity', 'sha256']);
279
+ if (!receipt || receipt.version !== 1 || receipt.nonce !== nonce || receipt.action !== action
280
+ || receipt.kind !== 'complete' || typeof receipt.sha256 !== 'string' || !SHA256.test(receipt.sha256)) {
281
+ return { kind: 'invalid' };
282
+ }
283
+ const rawIdentity = helperRecord(receipt.identity, ['root', 'chain', 'file']);
284
+ const readRoot = directoryIdentity(rawIdentity?.root);
285
+ const file = directoryIdentity(rawIdentity?.file);
286
+ const chain = Array.isArray(rawIdentity?.chain) ? rawIdentity.chain.map(directoryIdentity) : undefined;
287
+ if (!readRoot || readRoot.dev !== rootStatus.dev || readRoot.ino !== rootStatus.ino
288
+ || !file || !chain || chain.length !== parts.length - 1 || chain.some((entry) => !entry))
289
+ return { kind: 'invalid' };
290
+ const handle = await open(output, constants.O_RDONLY | constants.O_NOFOLLOW);
291
+ let bytes;
292
+ try {
293
+ const before = await handle.stat();
294
+ if (!before.isFile() || before.size > maxBytes)
295
+ return { kind: 'invalid' };
296
+ const chunks = [];
297
+ const chunk = Buffer.allocUnsafe(65_536);
298
+ let total = 0;
299
+ while (true) {
300
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, maxBytes - total + 1), null);
301
+ if (bytesRead === 0)
302
+ break;
303
+ total += bytesRead;
304
+ if (total > maxBytes)
305
+ return { kind: 'invalid' };
306
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
307
+ }
308
+ const after = await handle.stat();
309
+ if (total !== before.size || after.dev !== before.dev || after.ino !== before.ino
310
+ || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
311
+ return { kind: 'invalid' };
312
+ }
313
+ bytes = Buffer.concat(chunks, total);
314
+ }
315
+ finally {
316
+ await handle.close();
317
+ }
318
+ if (sha256Hex(bytes) !== receipt.sha256)
319
+ return { kind: 'invalid' };
320
+ return {
321
+ kind: 'complete', bytes, sha256: receipt.sha256,
322
+ identity: { root: readRoot, chain: chain, file },
323
+ };
324
+ }
325
+ catch {
326
+ // a failed anchored read is invalid; the caller refuses it.
327
+ return { kind: 'invalid' };
328
+ }
329
+ finally {
330
+ await rm(directory, { recursive: true, force: true });
331
+ }
332
+ }
333
+ export async function readAnchoredEntry(root, path, maxBytes, node, adapters) {
334
+ const target = confined(root, path);
335
+ if (!target || maxBytes < 1 || maxBytes > MAX_GENERATED_BYTES)
336
+ return { kind: 'invalid' };
337
+ const parts = relative(root, target).split(sep);
338
+ let rootStatus;
339
+ try {
340
+ rootStatus = await lstat(root);
341
+ }
342
+ catch { /* an unobservable root is an invalid anchored read the caller refuses. */
343
+ return { kind: 'invalid' };
344
+ }
345
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink())
346
+ return { kind: 'invalid' };
347
+ let directory;
348
+ try {
349
+ directory = await mkdtemp(join(tmpdir(), 'cockpit-generated-entry-'));
350
+ const nonce = randomBytes(16).toString('hex');
351
+ const output = join(directory, 'source');
352
+ const request = { version: 1, nonce, action: 'read-entry', maxBytes,
353
+ identity: { dev: rootStatus.dev, ino: rootStatus.ino }, segments: parts.slice(0, -1), name: parts.at(-1), output };
354
+ const value = await runHelper(adapters, helperStep(node, root, '.', request, [], Date.now() + HELPER_TIMEOUT_MS));
355
+ const simple = ownRecord(value);
356
+ if (!simple || simple.version !== 1 || simple.nonce !== nonce || simple.action !== 'read-entry' || typeof simple.kind !== 'string')
357
+ return { kind: 'invalid' };
358
+ if ((simple.kind === 'missing' || simple.kind === 'other') && exactKeys(simple, ['version', 'nonce', 'action', 'kind']))
359
+ return { kind: simple.kind };
360
+ if (simple.kind === 'symlink' && exactKeys(simple, ['version', 'nonce', 'action', 'kind', 'target', 'identity'])) {
361
+ const identity = directoryIdentity(simple.identity);
362
+ return identity && typeof simple.target === 'string' ? { kind: 'symlink', target: simple.target, identity } : { kind: 'invalid' };
363
+ }
364
+ if (simple.kind !== 'file' || !exactKeys(simple, ['version', 'nonce', 'action', 'kind', 'identity', 'mode', 'sha256'])
365
+ || typeof simple.sha256 !== 'string' || !SHA256.test(simple.sha256) || !Number.isSafeInteger(simple.mode))
366
+ return { kind: 'invalid' };
367
+ const identityRow = ownRecord(simple.identity);
368
+ const readRoot = directoryIdentity(identityRow?.root);
369
+ const file = directoryIdentity(identityRow?.file);
370
+ const chain = Array.isArray(identityRow?.chain) ? identityRow.chain.map(directoryIdentity) : undefined;
371
+ if (!readRoot || !file || !chain || chain.some(row => !row))
372
+ return { kind: 'invalid' };
373
+ const handle = await open(output, constants.O_RDONLY | constants.O_NOFOLLOW);
374
+ let bytes;
375
+ try {
376
+ const before = await handle.stat();
377
+ if (!before.isFile() || before.size > maxBytes)
378
+ return { kind: 'invalid' };
379
+ const chunks = [];
380
+ const chunk = Buffer.allocUnsafe(65_536);
381
+ let total = 0;
382
+ while (true) {
383
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, maxBytes - total + 1), null);
384
+ if (bytesRead === 0)
385
+ break;
386
+ total += bytesRead;
387
+ if (total > maxBytes)
388
+ return { kind: 'invalid' };
389
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
390
+ }
391
+ const after = await handle.stat();
392
+ if (total !== before.size || after.dev !== before.dev || after.ino !== before.ino
393
+ || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) {
394
+ return { kind: 'invalid' };
395
+ }
396
+ bytes = Buffer.concat(chunks, total);
397
+ }
398
+ finally {
399
+ await handle.close();
400
+ }
401
+ if (sha256Hex(bytes) !== simple.sha256)
402
+ return { kind: 'invalid' };
403
+ return { kind: 'file', bytes, sha256: simple.sha256, mode: simple.mode,
404
+ identity: { root: readRoot, chain: chain, file } };
405
+ }
406
+ catch { /* a failed anchored read is invalid; the caller refuses it. */
407
+ return { kind: 'invalid' };
408
+ }
409
+ finally {
410
+ if (directory)
411
+ await rm(directory, { recursive: true, force: true });
412
+ }
413
+ }
414
+ /** Move one authenticated regular file to a portable authored destination through the publication helper. */
415
+ export async function relocateAnchoredFile(root, sourcePath, destinationPath, maxBytes, expectedSha256, node, adapters) {
416
+ return relocateAnchoredFileWithDestinationPolicy(root, sourcePath, destinationPath, maxBytes, expectedSha256, node, adapters, 'relocate-file', safePublicationSegment);
417
+ }
418
+ async function relocateAnchoredFileWithDestinationPolicy(root, sourcePath, destinationPath, maxBytes, expectedSha256, node, adapters, action, destinationSegment) {
419
+ const sourceTarget = confined(root, sourcePath);
420
+ const destinationTarget = confined(root, destinationPath);
421
+ if (!sourceTarget || !destinationTarget || sourceTarget === destinationTarget || !SHA256.test(expectedSha256)
422
+ || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_GENERATED_BYTES) {
423
+ return { kind: 'refusal', recovery: 'unchanged' };
424
+ }
425
+ const sourceParts = relative(root, sourceTarget).split(sep);
426
+ const destinationParts = relative(root, destinationTarget).split(sep);
427
+ if (sourceParts.some(part => !safeAnchoredSegment(part)) || destinationParts.some(part => !destinationSegment(part))) {
428
+ return { kind: 'refusal', recovery: 'unchanged' };
429
+ }
430
+ const source = await readAnchoredFile(root, sourcePath, maxBytes, node, adapters);
431
+ const destination = await readAnchoredEntry(root, destinationPath, maxBytes, node, adapters);
432
+ if (source.kind !== 'complete' || source.sha256 !== expectedSha256 || destination.kind !== 'missing') {
433
+ return { kind: 'refusal', recovery: 'unchanged' };
434
+ }
435
+ const nonce = randomBytes(16).toString('hex');
436
+ const request = {
437
+ version: 1, nonce, action, identity: source.identity.root,
438
+ source: {
439
+ chain: sourceParts.slice(0, -1).map((name, index) => ({ name, ...source.identity.chain[index] })),
440
+ name: sourceParts.at(-1), file: source.identity.file, sha256: source.sha256, maxBytes,
441
+ },
442
+ destination: { segments: destinationParts.slice(0, -1), name: destinationParts.at(-1) },
443
+ };
444
+ const receipt = await runHelper(adapters, helperStep(node, root, 'adoption', request, [], Date.now() + HELPER_TIMEOUT_MS));
445
+ const complete = helperRecord(receipt, ['version', 'nonce', 'action', 'kind', 'sha256']);
446
+ if (complete?.version === 1 && complete.nonce === nonce && complete.action === action
447
+ && complete.kind === 'complete' && complete.sha256 === expectedSha256) {
448
+ return { kind: 'complete', sha256: expectedSha256 };
449
+ }
450
+ const refusal = ownRecord(receipt);
451
+ const safeFailure = (value) => typeof value === 'string' && value.length > 0 && value.length <= 512;
452
+ const recovered = refusal && exactKeys(refusal, ['version', 'nonce', 'action', 'kind', 'reason', 'failure'])
453
+ && refusal.version === 1 && refusal.nonce === nonce && refusal.action === action
454
+ && refusal.kind === 'refusal' && refusal.reason === 'relocate-failed' && safeFailure(refusal.failure);
455
+ const recoveryFailed = refusal && exactKeys(refusal, ['version', 'nonce', 'action', 'kind', 'reason', 'failure', 'recoveryFailures', 'retained'])
456
+ && refusal.version === 1 && refusal.nonce === nonce && refusal.action === action
457
+ && refusal.kind === 'refusal' && refusal.reason === 'relocate-recovery-failed' && Array.isArray(refusal.retained)
458
+ && safeFailure(refusal.failure) && Array.isArray(refusal.recoveryFailures)
459
+ && refusal.recoveryFailures.length > 0 && refusal.recoveryFailures.every(safeFailure)
460
+ && refusal.retained.every(path => typeof path === 'string' && confined(root, path) !== undefined);
461
+ const retainedPaths = recoveryFailed
462
+ ? refusal.retained : undefined;
463
+ const possibleCaptures = [
464
+ [...sourceParts.slice(0, -1), `.cockpit-adopt-source-${nonce}`].join('/'),
465
+ [...destinationParts.slice(0, -1), `.cockpit-adopt-destination-${nonce}`].join('/'),
466
+ ];
467
+ return {
468
+ kind: 'refusal', recovery: recovered ? 'unchanged' : 'indeterminate',
469
+ ...((recovered || recoveryFailed) ? { failure: refusal.failure } : { failure: 'filesystem-helper-receipt-unavailable' }),
470
+ ...(recoveryFailed ? { recoveryFailures: refusal.recoveryFailures } : {}),
471
+ ...(!recovered ? { retainedPaths: retainedPaths ?? possibleCaptures } : {}),
472
+ };
473
+ }
474
+ /** Confirm or restore the original side of an anchored relocation without following either path. */
475
+ export async function restoreAnchoredFile(root, originalPath, relocatedPath, maxBytes, expectedSha256, node, adapters) {
476
+ const original = await readAnchoredFile(root, originalPath, maxBytes, node, adapters);
477
+ const relocated = await readAnchoredFile(root, relocatedPath, maxBytes, node, adapters);
478
+ if (original.kind === 'complete' && original.sha256 === expectedSha256 && relocated.kind === 'missing') {
479
+ return { kind: 'complete', sha256: expectedSha256 };
480
+ }
481
+ if (original.kind === 'missing' && relocated.kind === 'complete' && relocated.sha256 === expectedSha256) {
482
+ return relocateAnchoredFileWithDestinationPolicy(root, relocatedPath, originalPath, maxBytes, expectedSha256, node, adapters, 'restore-file', safeAnchoredSegment);
483
+ }
484
+ return { kind: 'refusal', recovery: 'indeterminate' };
485
+ }
486
+ async function readPrivateOutput(path, maxBytes) {
487
+ let handle;
488
+ let result;
489
+ let closeFailed = false;
490
+ try {
491
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
492
+ const before = await handle.stat();
493
+ if (!before.isFile() || before.size > maxBytes)
494
+ return undefined;
495
+ const chunks = [];
496
+ const chunk = Buffer.allocUnsafe(65_536);
497
+ let total = 0;
498
+ while (true) {
499
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, maxBytes - total + 1), null);
500
+ if (bytesRead === 0)
501
+ break;
502
+ total += bytesRead;
503
+ if (total > maxBytes)
504
+ return undefined;
505
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
506
+ }
507
+ const after = await handle.stat();
508
+ result = total === before.size && after.dev === before.dev && after.ino === before.ino
509
+ && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs
510
+ ? Buffer.concat(chunks, total) : undefined;
511
+ }
512
+ catch {
513
+ // an unavailable authority is refused by the caller.
514
+ return undefined;
515
+ }
516
+ finally {
517
+ try {
518
+ await handle?.close();
519
+ }
520
+ catch { /* A descriptor that cannot be closed voids the read, so its result is discarded. */
521
+ closeFailed = true;
522
+ }
523
+ }
524
+ return closeFailed ? undefined : result;
525
+ }
526
+ async function readAnchoredBatch(root, requests, node, adapters, deadline) {
527
+ if (requests.length < 1 || requests.length > MAX_AUTHENTICATED_READ_BATCH || constants.O_NOFOLLOW === undefined
528
+ || requests.reduce((total, request) => total + request.maxBytes, 0) > MAX_AUTHENTICATED_READ_BATCH_BYTES)
529
+ return undefined;
530
+ const resolved = requests.map((request) => {
531
+ const target = confined(root, request.path);
532
+ if (!target || !Number.isSafeInteger(request.maxBytes) || request.maxBytes < 1 || request.maxBytes > MAX_GENERATED_BYTES)
533
+ return undefined;
534
+ const parts = relative(root, target).split(sep);
535
+ return parts.length > 0 && parts.every(part => part !== '' && part !== '.' && part !== '..')
536
+ ? { ...request, target, parts } : undefined;
537
+ });
538
+ if (resolved.some(request => request === undefined)
539
+ || new Set(resolved.map(request => request?.target)).size !== resolved.length)
540
+ return undefined;
541
+ if (requests.length === 1) {
542
+ const single = requests[0];
543
+ const result = single.action === 'read-entry'
544
+ ? await readAnchoredEntry(root, single.path, single.maxBytes, node, adapters)
545
+ : await readAnchoredFile(root, single.path, single.maxBytes, node, adapters, deadline);
546
+ return result.kind === 'invalid' ? { kind: 'invalid', index: 0 }
547
+ : { kind: 'complete', results: [result] };
548
+ }
549
+ let rootStatus;
550
+ try {
551
+ rootStatus = await lstat(root);
552
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink())
553
+ return undefined;
554
+ }
555
+ catch {
556
+ // an unavailable authority is refused by the caller.
557
+ return undefined;
558
+ }
559
+ let directory;
560
+ try {
561
+ directory = await mkdtemp(join(tmpdir(), 'cockpit-generated-read-batch-'));
562
+ const nonce = randomBytes(16).toString('hex');
563
+ const entries = resolved.map((request, index) => ({
564
+ nonce: randomBytes(16).toString('hex'),
565
+ action: request.action,
566
+ maxBytes: request.maxBytes,
567
+ segments: request.parts.slice(0, -1),
568
+ name: request.parts.at(-1),
569
+ output: join(directory, `source-${index}`),
570
+ }));
571
+ if (new Set(entries.map(entry => entry.nonce)).size !== entries.length)
572
+ return undefined;
573
+ const receiptOutput = join(directory, 'receipt');
574
+ const request = {
575
+ version: 1, nonce, action: 'read-batch',
576
+ identity: { dev: rootStatus.dev, ino: rootStatus.ino },
577
+ entries,
578
+ receiptOutput,
579
+ };
580
+ if (helperFrameBytes(request) > MAX_HELPER_FRAME_BYTES)
581
+ return { kind: 'split' };
582
+ const helperDeadline = Math.min(Date.now() + HELPER_TIMEOUT_MS, deadline ?? Number.MAX_SAFE_INTEGER);
583
+ const raw = await runHelper(adapters, helperStep(node, root, '.', request, [], helperDeadline));
584
+ const refusal = helperRecord(raw, ['version', 'nonce', 'action', 'kind', 'failedNonce']);
585
+ if (refusal?.version === 1 && refusal.nonce === nonce && refusal.action === 'read-batch' && refusal.kind === 'refusal'
586
+ && typeof refusal.failedNonce === 'string') {
587
+ const index = entries.findIndex(entry => entry.nonce === refusal.failedNonce);
588
+ return index >= 0 ? { kind: 'invalid', index } : undefined;
589
+ }
590
+ const summary = helperRecord(raw, ['version', 'nonce', 'action', 'kind', 'receiptBytes', 'receiptSha256']);
591
+ if (!summary || summary.version !== 1 || summary.nonce !== nonce || summary.action !== 'read-batch'
592
+ || summary.kind !== 'complete' || !Number.isSafeInteger(summary.receiptBytes)
593
+ || summary.receiptBytes < 1 || summary.receiptBytes > MAX_AUTHENTICATED_READ_BATCH_RECEIPT_BYTES
594
+ || typeof summary.receiptSha256 !== 'string' || !SHA256.test(summary.receiptSha256))
595
+ return undefined;
596
+ const receiptBytes = await readPrivateOutput(receiptOutput, MAX_AUTHENTICATED_READ_BATCH_RECEIPT_BYTES);
597
+ if (!receiptBytes || receiptBytes.length !== summary.receiptBytes || sha256Hex(receiptBytes) !== summary.receiptSha256)
598
+ return undefined;
599
+ const receiptText = new TextDecoder('utf-8', { fatal: true }).decode(receiptBytes);
600
+ const receiptValue = JSON.parse(receiptText);
601
+ if (JSON.stringify(receiptValue) + '\n' !== receiptText)
602
+ return undefined;
603
+ const receipt = helperRecord(receiptValue, ['version', 'nonce', 'action', 'kind', 'entries']);
604
+ if (!receipt || receipt.version !== 1 || receipt.nonce !== nonce || receipt.action !== 'read-batch'
605
+ || receipt.kind !== 'complete' || !Array.isArray(receipt.entries) || receipt.entries.length !== entries.length)
606
+ return undefined;
607
+ const results = [];
608
+ for (const [index, expected] of entries.entries()) {
609
+ const row = ownRecord(receipt.entries[index]);
610
+ if (!row || row.nonce !== expected.nonce || row.action !== expected.action || row.name !== expected.name
611
+ || row.maxBytes !== expected.maxBytes || typeof row.kind !== 'string')
612
+ return undefined;
613
+ if (!Array.isArray(row.segments) || row.segments.length !== expected.segments.length
614
+ || row.segments.some((segment, segmentIndex) => segment !== expected.segments[segmentIndex]))
615
+ return undefined;
616
+ const boundKeys = ['nonce', 'action', 'segments', 'name', 'maxBytes', 'kind'];
617
+ if (row.kind === 'missing') {
618
+ if (!exactKeys(row, boundKeys))
619
+ return undefined;
620
+ results.push({ kind: 'missing' });
621
+ continue;
622
+ }
623
+ if (row.kind === 'other') {
624
+ if (expected.action !== 'read-entry' || !exactKeys(row, boundKeys))
625
+ return undefined;
626
+ results.push({ kind: 'other' });
627
+ continue;
628
+ }
629
+ if (row.kind === 'symlink') {
630
+ if (expected.action !== 'read-entry' || !exactKeys(row, [...boundKeys, 'target', 'identity']) || typeof row.target !== 'string')
631
+ return undefined;
632
+ const identity = directoryIdentity(row.identity);
633
+ if (!identity)
634
+ return undefined;
635
+ results.push({ kind: 'symlink', target: row.target, identity });
636
+ continue;
637
+ }
638
+ const expectedKind = expected.action === 'read-entry' ? 'file' : 'complete';
639
+ const keys = expected.action === 'read-entry'
640
+ ? [...boundKeys, 'identity', 'mode', 'sha256'] : [...boundKeys, 'identity', 'sha256'];
641
+ if (row.kind !== expectedKind || !exactKeys(row, keys) || typeof row.sha256 !== 'string' || !SHA256.test(row.sha256)
642
+ || expected.action === 'read-entry' && !Number.isSafeInteger(row.mode))
643
+ return undefined;
644
+ const identityRow = helperRecord(row.identity, ['root', 'chain', 'file']);
645
+ const readRoot = directoryIdentity(identityRow?.root);
646
+ const file = directoryIdentity(identityRow?.file);
647
+ const chain = Array.isArray(identityRow?.chain) ? identityRow.chain.map(directoryIdentity) : undefined;
648
+ const parts = resolved[index].parts;
649
+ if (!readRoot || readRoot.dev !== rootStatus.dev || readRoot.ino !== rootStatus.ino || !file || !chain
650
+ || chain.length !== parts.length - 1 || chain.some(entry => !entry))
651
+ return undefined;
652
+ const bytes = await readPrivateOutput(expected.output, expected.maxBytes);
653
+ if (!bytes || sha256Hex(bytes) !== row.sha256)
654
+ return { kind: 'invalid', index };
655
+ const identity = { root: readRoot, chain: chain, file };
656
+ results.push(expected.action === 'read-entry'
657
+ ? { kind: 'file', bytes, sha256: row.sha256, mode: row.mode, identity }
658
+ : { kind: 'complete', bytes, sha256: row.sha256, identity });
659
+ }
660
+ return { kind: 'complete', results };
661
+ }
662
+ catch {
663
+ // an unavailable authority is refused by the caller.
664
+ return undefined;
665
+ }
666
+ finally {
667
+ if (directory)
668
+ await rm(directory, { recursive: true, force: true });
669
+ }
670
+ }
671
+ export async function readAnchoredBatches(root, requests, node, adapters, deadline) {
672
+ const results = [];
673
+ let offset = 0;
674
+ while (offset < requests.length) {
675
+ if (deadline !== undefined && Date.now() >= deadline)
676
+ return undefined;
677
+ let end = offset;
678
+ let requestedBytes = 0;
679
+ const targets = new Set();
680
+ while (end < requests.length && end - offset < MAX_AUTHENTICATED_READ_BATCH) {
681
+ const next = requests[end];
682
+ const target = confined(root, next.path);
683
+ if (target && targets.has(target))
684
+ break;
685
+ if (requestedBytes + next.maxBytes > MAX_AUTHENTICATED_READ_BATCH_BYTES)
686
+ break;
687
+ if (target)
688
+ targets.add(target);
689
+ requestedBytes += next.maxBytes;
690
+ end += 1;
691
+ }
692
+ if (end === offset)
693
+ return undefined;
694
+ let batch = await readAnchoredBatch(root, requests.slice(offset, end), node, adapters, deadline);
695
+ while (batch?.kind === 'split') {
696
+ end = offset + Math.ceil((end - offset) / 2);
697
+ batch = await readAnchoredBatch(root, requests.slice(offset, end), node, adapters, deadline);
698
+ }
699
+ if (!batch)
700
+ return undefined;
701
+ if (batch.kind === 'invalid')
702
+ return { kind: 'invalid', index: offset + batch.index };
703
+ results.push(...batch.results);
704
+ offset = end;
705
+ }
706
+ return { kind: 'complete', results };
707
+ }
708
+ export async function readAnchoredConfigSource(root, path, node, adapters, deadline) {
709
+ const read = await readAnchoredFile(root, path, MAX_GENERATED_BYTES, node, adapters, deadline);
710
+ return read.kind === 'complete'
711
+ ? { bytes: read.bytes, sha256: read.sha256, identity: read.identity }
712
+ : undefined;
713
+ }
714
+ function safeAnchoredSegment(value) {
715
+ return typeof value === 'string' && value.length > 0 && value !== '.' && value !== '..'
716
+ && !value.includes('/') && !value.includes('\\') && !value.includes('\0');
717
+ }
718
+ function safePublicationSegment(value) {
719
+ return typeof value === 'string' && value.length > 0 && value.length <= 255 && Buffer.byteLength(value, 'utf8') <= 255
720
+ && !/[<>:"/\\|?*\x00-\x1f\x7f]/.test(value) && !/[. ]$/.test(value)
721
+ && !/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value);
722
+ }
723
+ export function portablePublicationPaths(paths) {
724
+ const prefixes = new Map();
725
+ for (const path of paths) {
726
+ let prefix = '';
727
+ for (const segment of path.split('/')) {
728
+ if (!safePublicationSegment(segment))
729
+ return false;
730
+ prefix = prefix ? `${prefix}/${segment}` : segment;
731
+ // Missing parents have no inode to compare, so reject aliases before either spelling can be created.
732
+ const key = prefix.normalize('NFC').toUpperCase().normalize('NFC');
733
+ const prior = prefixes.get(key);
734
+ if (prior !== undefined && prior !== prefix)
735
+ return false;
736
+ prefixes.set(key, prefix);
737
+ }
738
+ }
739
+ return true;
740
+ }
741
+ function helperChain(value) {
742
+ if (!Array.isArray(value))
743
+ return undefined;
744
+ const chain = [];
745
+ for (const item of value) {
746
+ const row = helperRecord(item, ['name', 'dev', 'ino', 'created']);
747
+ if (!row || !safePublicationSegment(row.name) || !Number.isSafeInteger(row.dev)
748
+ || !Number.isSafeInteger(row.ino) || typeof row.created !== 'boolean')
749
+ return undefined;
750
+ chain.push({ name: row.name, dev: row.dev, ino: row.ino, created: row.created });
751
+ }
752
+ return chain;
753
+ }
754
+ // Directories a prepare batch created, keyed by their path from the transaction root. The first row
755
+ // naming a missing directory must report creating it; a later group or entry that walks through it
756
+ // must report the same inode as already present.
757
+ function createdRowsValid(prefix, rows, created) {
758
+ const names = [...prefix];
759
+ for (const row of rows) {
760
+ names.push(row.name);
761
+ const key = names.join('/');
762
+ const prior = created.get(key);
763
+ if (prior) {
764
+ if (row.created || row.dev !== prior.dev || row.ino !== prior.ino)
765
+ return false;
766
+ }
767
+ else {
768
+ if (!row.created)
769
+ return false;
770
+ created.set(key, { dev: row.dev, ino: row.ino });
771
+ }
772
+ }
773
+ return true;
774
+ }
775
+ function preparedEntryChainsValid(entries, requests, prefix, created) {
776
+ for (const [entryIndex, entry] of entries.entries()) {
777
+ const request = requests[entryIndex];
778
+ if (!request || entry.chain.length !== request.chain.length + request.segments.length)
779
+ return false;
780
+ const existing = entry.chain.slice(0, request.chain.length);
781
+ const missing = entry.chain.slice(request.chain.length);
782
+ if (existing.some((directory, index) => directory.name !== request.chain[index].name || directory.dev !== request.chain[index].dev
783
+ || directory.ino !== request.chain[index].ino || directory.created)
784
+ || missing.some((directory, index) => directory.name !== request.segments[index])
785
+ || !createdRowsValid([...prefix, ...existing.map(directory => directory.name)], missing, created))
786
+ return false;
787
+ }
788
+ return true;
789
+ }
790
+ function parsePreparedGroup(value, row, created) {
791
+ const receipt = helperRecord(value, ['nonce', 'identity', 'chain', 'entries']);
792
+ if (!receipt || receipt.nonce !== row.request.nonce || !Array.isArray(receipt.entries))
793
+ return undefined;
794
+ const identity = directoryIdentity(receipt.identity);
795
+ const chain = helperChain(receipt.chain);
796
+ const final = chain?.at(-1) ?? row.anchor.identity;
797
+ if (!identity || !chain || chain.length !== row.anchor.segments.length
798
+ || chain.some((directory, index) => directory.name !== row.anchor.segments[index])
799
+ || !createdRowsValid(row.anchor.chain.map(directory => directory.name), chain, created)
800
+ || final.dev !== identity.dev || final.ino !== identity.ino
801
+ || receipt.entries.length !== row.group.mutations.length)
802
+ return undefined;
803
+ const requests = row.request.entries;
804
+ const entries = [];
805
+ for (const [index, item] of receipt.entries.entries()) {
806
+ const entry = helperRecord(item, ['chain', 'name', 'operation', 'sha256', 'target', 'stage', 'backup', 'priorKind', 'priorSha256', 'priorTarget', 'priorMode', 'priorIdentity']);
807
+ const priorIdentity = entry ? entry.priorIdentity === null ? null : directoryIdentity(entry.priorIdentity) : undefined;
808
+ const entryChain = entry ? helperChain(entry.chain) : undefined;
809
+ const request = requests[index];
810
+ const mutation = row.group.mutations[index];
811
+ const expectedStage = `.cockpit-stage-${row.request.nonce}-${index}`;
812
+ const expectedBackup = `.cockpit-backup-${row.request.nonce}-${index}`;
813
+ if (!entry || !request || !mutation || !entryChain || !safePublicationSegment(entry.name) || !['write-file', 'write-symlink', 'remove-file', 'remove-symlink'].includes(String(entry.operation))
814
+ || entry.stage !== null && typeof entry.stage !== 'string' || entry.sha256 !== null && (typeof entry.sha256 !== 'string' || !SHA256.test(entry.sha256))
815
+ || entry.target !== null && typeof entry.target !== 'string'
816
+ || entry.backup !== null && typeof entry.backup !== 'string'
817
+ || !['missing', 'file', 'symlink'].includes(String(entry.priorKind))
818
+ || entry.priorSha256 !== null && (typeof entry.priorSha256 !== 'string' || !SHA256.test(entry.priorSha256))
819
+ || entry.priorTarget !== null && typeof entry.priorTarget !== 'string'
820
+ || entry.priorMode !== null && (!Number.isSafeInteger(entry.priorMode) || entry.priorMode < 0 || entry.priorMode > 0o777)
821
+ || priorIdentity === undefined || (entry.priorKind === 'missing') !== (priorIdentity === null)
822
+ || (entry.priorKind === 'file') !== (entry.priorSha256 !== null) || (entry.priorKind === 'file') !== (entry.priorMode !== null)
823
+ || (entry.priorKind === 'file') !== (entry.backup !== null) || (entry.priorKind === 'symlink') !== (entry.priorTarget !== null)
824
+ || entry.stage !== (request.operation === 'write-file' || request.operation === 'write-symlink' ? expectedStage : null)
825
+ || entry.backup !== (entry.priorKind === 'file' ? expectedBackup : null)
826
+ || entry.name !== basename(mutation.path) || entry.operation !== mutation.operation
827
+ || entry.sha256 !== (mutation.sha256 ?? null) || entry.target !== (mutation.target ?? null))
828
+ return undefined;
829
+ entries.push({ ...entry, chain: entryChain, priorIdentity });
830
+ }
831
+ const parent = row.group.parent === '.' ? [] : row.group.parent.split('/');
832
+ if (new Set(entries.map(entry => [...entry.chain.map(directory => directory.name), entry.name].join('/'))).size !== entries.length
833
+ || !preparedEntryChainsValid(entries, requests, parent, created))
834
+ return undefined;
835
+ return {
836
+ parent: row.group.parent,
837
+ path: row.path,
838
+ start: row.anchor.identity,
839
+ identity,
840
+ chain,
841
+ entries,
842
+ logicalWeight: row.group.logicalWeight,
843
+ };
844
+ }
845
+ // A refusal still lists the groups prepared before it, so the caller can roll those back.
846
+ function parsePrepareBatchReceipt(value, batch) {
847
+ const receipt = ownRecord(value);
848
+ if (!receipt || receipt.version !== 1 || receipt.nonce !== batch.request.nonce || receipt.action !== 'prepare-batch'
849
+ || !Array.isArray(receipt.groups))
850
+ return undefined;
851
+ const complete = receipt.kind === 'complete' && exactKeys(receipt, ['version', 'nonce', 'action', 'kind', 'groups'])
852
+ && receipt.groups.length === batch.rows.length;
853
+ const refused = receipt.kind === 'refusal' && exactKeys(receipt, ['version', 'nonce', 'action', 'kind', 'reason', 'groups'])
854
+ && (receipt.reason === 'prepare-failed' || receipt.reason === 'prepare-cleanup-failed') && receipt.groups.length < batch.rows.length;
855
+ if (!complete && !refused)
856
+ return undefined;
857
+ const created = new Map();
858
+ const groups = [];
859
+ for (const [index, item] of receipt.groups.entries()) {
860
+ const group = parsePreparedGroup(item, batch.rows[index], created);
861
+ if (!group)
862
+ return undefined;
863
+ groups.push(group);
864
+ }
865
+ return { complete, groups };
866
+ }
867
+ function parseRollbackReceipt(value, nonce) {
868
+ const receipt = helperRecord(value, ['version', 'nonce', 'action', 'kind']);
869
+ return receipt?.version === 1 && receipt.nonce === nonce && receipt.action === 'rollback' && receipt.kind === 'complete';
870
+ }
871
+ function parsePublicationBatchReceipt(value, request) {
872
+ const refusal = helperRecord(value, ['version', 'nonce', 'action', 'kind', 'reason']);
873
+ if (request.action === 'commit-batch' && refusal?.version === 1 && refusal.nonce === request.nonce && refusal.action === request.action
874
+ && refusal.kind === 'refusal' && refusal.reason === 'verify-failed')
875
+ return 'verify-failed';
876
+ const receipt = helperRecord(value, ['version', 'nonce', 'action', 'kind', 'groups']);
877
+ if (!receipt || receipt.version !== 1 || receipt.nonce !== request.nonce || receipt.action !== request.action
878
+ || receipt.kind !== 'complete' || !Array.isArray(receipt.groups) || receipt.groups.length !== request.groups.length)
879
+ return 'failed';
880
+ const nonces = new Set();
881
+ return receipt.groups.every((value, index) => {
882
+ const row = helperRecord(value, ['nonce', 'parent', 'kind']);
883
+ const expected = request.groups[index];
884
+ if (!row || !expected || typeof row.nonce !== 'string' || nonces.has(row.nonce))
885
+ return false;
886
+ nonces.add(row.nonce);
887
+ return row.nonce === expected.nonce && row.parent === expected.parent && row.kind === 'complete';
888
+ }) ? 'complete' : 'failed';
889
+ }
890
+ async function publicationBatchRequest(root, rootIdentity, groups, action) {
891
+ const rootStatus = await lstat(root);
892
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()
893
+ || rootStatus.dev !== rootIdentity.dev || rootStatus.ino !== rootIdentity.ino)
894
+ return undefined;
895
+ const nonce = randomBytes(16).toString('hex');
896
+ const rows = [];
897
+ for (const group of groups.slice(0, MAX_PUBLICATION_BATCH_GROUPS)) {
898
+ const target = group.parent === '.' ? root : confined(root, group.parent);
899
+ if (!target || target !== group.path)
900
+ return undefined;
901
+ let current = root;
902
+ const chain = [];
903
+ for (const name of group.parent === '.' ? [] : group.parent.split('/')) {
904
+ if (name.length === 0 || name === '.' || name === '..' || name.includes('\\') || name.includes('\0'))
905
+ return undefined;
906
+ current = resolve(current, name);
907
+ const status = await lstat(current);
908
+ if (!status.isDirectory() || status.isSymbolicLink())
909
+ return undefined;
910
+ chain.push({ name, dev: status.dev, ino: status.ino });
911
+ }
912
+ const final = chain.at(-1) ?? rootIdentity;
913
+ if (final.dev !== group.identity.dev || final.ino !== group.identity.ino)
914
+ return undefined;
915
+ const row = {
916
+ nonce: randomBytes(16).toString('hex'),
917
+ parent: group.parent,
918
+ identity: group.identity,
919
+ chain,
920
+ entries: group.entries,
921
+ };
922
+ const candidate = { version: 1, nonce, action, identity: rootIdentity, groups: [...rows, row] };
923
+ if (helperFrameBytes(candidate) > MAX_HELPER_FRAME_BYTES)
924
+ break;
925
+ rows.push(row);
926
+ }
927
+ return rows.length > 0
928
+ ? { version: 1, nonce, action, identity: rootIdentity, groups: rows }
929
+ : undefined;
930
+ }
931
+ // `verify-failed` is reported only while no request of this run has completed, so it means that
932
+ // nothing this run would change has changed.
933
+ async function runPublicationBatches(action, root, rootIdentity, groups, node, adapters, nextForwardDeadline, callsPerGroup = 1, first) {
934
+ let offset = 0;
935
+ while (offset < groups.length) {
936
+ const request = offset === 0 && first ? first : await publicationBatchRequest(root, rootIdentity, groups.slice(offset), action);
937
+ const logicalWeight = request?.groups.reduce((count, _row, index) => count + groups[offset + index].logicalWeight, 0);
938
+ const deadline = logicalWeight === undefined ? undefined : nextForwardDeadline(callsPerGroup * logicalWeight);
939
+ if (!request || deadline === undefined)
940
+ return offset === 0 ? 'verify-failed' : 'failed';
941
+ const receipt = await runHelper(adapters, helperStep(node, root, 'generated-environment', request, [], deadline));
942
+ const outcome = parsePublicationBatchReceipt(receipt, request);
943
+ if (outcome !== 'complete')
944
+ return outcome === 'verify-failed' && offset === 0 ? 'verify-failed' : 'failed';
945
+ offset += request.groups.length;
946
+ }
947
+ return 'complete';
948
+ }
949
+ // publish-batch validates every group in its request before it publishes any, and commit-batch
950
+ // verifies every group before it retires any backup. A transaction that fits one request therefore
951
+ // needs one helper call per stage; a larger one first checks every group across separate requests,
952
+ // so no request publishes or commits while a later group is unchecked. Either way the stage spends
953
+ // two forward slots per group. `verify-failed` means no backup was retired.
954
+ async function runPublicationStage(stage, root, rootIdentity, groups, node, adapters, nextForwardDeadline) {
955
+ const check = stage === 'publish' ? 'validate-batch' : 'verify-batch';
956
+ const action = stage === 'publish' ? 'publish-batch' : 'commit-batch';
957
+ const single = await publicationBatchRequest(root, rootIdentity, groups, action);
958
+ if (!single)
959
+ return 'verify-failed';
960
+ // The request that decided this path is the one sent, so it cannot split after the decision.
961
+ if (single.groups.length === groups.length) {
962
+ return runPublicationBatches(action, root, rootIdentity, groups, node, adapters, nextForwardDeadline, 2, single);
963
+ }
964
+ const checked = await runPublicationBatches(check, root, rootIdentity, groups, node, adapters, nextForwardDeadline);
965
+ if (checked !== 'complete')
966
+ return 'verify-failed';
967
+ return runPublicationBatches(action, root, rootIdentity, groups, node, adapters, nextForwardDeadline);
968
+ }
969
+ async function runHelper(adapters, step) {
970
+ if (!step)
971
+ return undefined;
972
+ try {
973
+ const outcome = authenticatedExecutionOutcome(await adapters.execute(step), step);
974
+ if (!outcome || outcome.kind !== 'complete' || outcome.stderr.byteLength !== 0)
975
+ return undefined;
976
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(outcome.stdout);
977
+ const value = JSON.parse(text);
978
+ return `${JSON.stringify(value)}\n` === text ? value : undefined;
979
+ }
980
+ catch {
981
+ // unparseable input is malformed; the caller refuses it.
982
+ return undefined;
983
+ }
984
+ }
985
+ export function expectedState(entry) {
986
+ if (entry.kind === 'missing')
987
+ return { kind: 'missing' };
988
+ if (entry.kind === 'file')
989
+ return { kind: 'file', sha256: entry.sha256, identity: entry.identity.file };
990
+ if (entry.kind === 'symlink')
991
+ return { kind: 'symlink', target: entry.target, identity: entry.identity };
992
+ return undefined;
993
+ }
994
+ async function stageInputs(files) {
995
+ let directory;
996
+ try {
997
+ directory = await mkdtemp(join(tmpdir(), 'cockpit-generated-input-'));
998
+ const inputs = [];
999
+ for (const [index, file] of files.entries()) {
1000
+ if (Buffer.byteLength(file.bytes) > MAX_GENERATED_BYTES)
1001
+ throw new RefusalError('generated input is oversized');
1002
+ const path = join(directory, String(index));
1003
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
1004
+ try {
1005
+ await handle.writeFile(file.bytes);
1006
+ await handle.sync();
1007
+ }
1008
+ finally {
1009
+ await handle.close();
1010
+ }
1011
+ inputs.push({ path, sha256: file.sha256 });
1012
+ }
1013
+ return { directory, inputs };
1014
+ }
1015
+ catch {
1016
+ // staging failed; the caller refuses and no inputs are retained.
1017
+ if (directory)
1018
+ await rm(directory, { recursive: true, force: true });
1019
+ return undefined;
1020
+ }
1021
+ }
1022
+ async function recordRecovery(directory, groups) {
1023
+ const path = join(directory, `recovery-${String(groups.length).padStart(4, '0')}.json`);
1024
+ let handle;
1025
+ let directoryHandle;
1026
+ try {
1027
+ handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
1028
+ const durableGroups = groups.map(({ logicalWeight: _logicalWeight, ...group }) => group);
1029
+ await handle.writeFile(`${JSON.stringify({ version: 1, groups: durableGroups })}\n`);
1030
+ await handle.sync();
1031
+ await handle.close();
1032
+ handle = undefined;
1033
+ directoryHandle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
1034
+ await directoryHandle.sync();
1035
+ return true;
1036
+ }
1037
+ catch {
1038
+ // a recovery record that is not durable keeps publication closed.
1039
+ return false;
1040
+ }
1041
+ finally {
1042
+ try {
1043
+ await handle?.close();
1044
+ }
1045
+ catch { /* A missing durable recovery record keeps publication closed. */ }
1046
+ try {
1047
+ await directoryHandle?.close();
1048
+ }
1049
+ catch { /* The recovery record was already synced before directory cleanup. */ }
1050
+ }
1051
+ }
1052
+ function publicationEntrySegments(parent, path) {
1053
+ const group = parent === '.' ? [] : parent.split('/');
1054
+ const leafParent = dirname(path) === '.' ? [] : dirname(path).split('/');
1055
+ if (group.some((segment, index) => segment !== leafParent[index]))
1056
+ return undefined;
1057
+ const segments = leafParent.slice(group.length);
1058
+ return segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..'
1059
+ && !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0')) ? segments : undefined;
1060
+ }
1061
+ function groupFrameBounded(parent, entries) {
1062
+ const identity = { dev: Number.MAX_SAFE_INTEGER, ino: Number.MAX_SAFE_INTEGER };
1063
+ const chain = (parent === '.' ? [] : parent.split('/')).map(name => ({ name, ...identity, created: false }));
1064
+ const requestEntries = entries.map((entry, index) => {
1065
+ const segments = publicationEntrySegments(parent, entry.path);
1066
+ return segments && { chain: segments.map(name => ({ name, ...identity })), segments: [], name: basename(entry.path), operation: entry.operation,
1067
+ expected: entry.expected, sha256: entry.sha256 ?? null, target: entry.target ?? null,
1068
+ input: entry.operation === 'write-file' ? Number.MAX_SAFE_INTEGER - index : null };
1069
+ });
1070
+ if (requestEntries.some(entry => entry === undefined))
1071
+ return false;
1072
+ const receiptEntries = entries.map((entry, index) => ({ chain: publicationEntrySegments(parent, entry.path).map(name => ({ name, ...identity, created: false })),
1073
+ name: basename(entry.path), operation: entry.operation,
1074
+ sha256: entry.sha256 ?? null, target: entry.target ?? null, stage: `.cockpit-stage-${'0'.repeat(32)}-${index}`,
1075
+ backup: `.cockpit-backup-${'0'.repeat(32)}-${index}`, priorKind: entry.expected.kind,
1076
+ priorSha256: entry.expected.sha256 ?? null, priorTarget: entry.expected.target ?? null,
1077
+ priorMode: entry.expected.kind === 'file' ? 511 : null, priorIdentity: entry.expected.identity ?? null }));
1078
+ const nonce = '0'.repeat(32);
1079
+ const group = { nonce, parent, identity, chain, entries: receiptEntries };
1080
+ const anchor = chain.map(({ created: _created, ...row }) => row);
1081
+ const requests = [
1082
+ { version: 1, nonce, action: 'prepare-batch', identity, groups: [{ nonce, parent, anchor, identity, segments: [], entries: requestEntries }] },
1083
+ { version: 1, nonce, action: 'validate-batch', identity, groups: [group] },
1084
+ { version: 1, nonce, action: 'publish-batch', identity, groups: [group] },
1085
+ { version: 1, nonce, action: 'verify-batch', identity, groups: [group] },
1086
+ { version: 1, nonce, action: 'rollback', identity, start: identity, chain, entries: receiptEntries },
1087
+ { version: 1, nonce, action: 'commit-batch', identity, groups: [group] },
1088
+ ];
1089
+ const receipt = { version: 1, nonce, action: 'prepare-batch', kind: 'complete', groups: [{ nonce, identity, chain, entries: receiptEntries }] };
1090
+ return requests.every(frame => helperFrameBytes(frame) + 1 <= MAX_HELPER_FRAME_BYTES)
1091
+ && Buffer.byteLength(JSON.stringify(receipt)) + 1 <= MAX_HELPER_FRAME_BYTES;
1092
+ }
1093
+ function groupMutations(mutations) {
1094
+ const grouped = new Map();
1095
+ for (const mutation of mutations) {
1096
+ const parent = dirname(mutation.path);
1097
+ const phase = mutation.phase === 'ledger' ? 'ledger' : mutation.operation.startsWith('write-') ? 'write' : 'remove';
1098
+ const key = `${phase}\0${parent}`;
1099
+ const group = grouped.get(key) ?? { parent, phase, rows: [] };
1100
+ group.rows.push(mutation);
1101
+ grouped.set(key, group);
1102
+ }
1103
+ const logical = [];
1104
+ const phaseOrder = { write: 0, remove: 1, ledger: 2 };
1105
+ for (const { parent, phase, rows } of [...grouped.values()].sort((left, right) => phaseOrder[left.phase] - phaseOrder[right.phase]
1106
+ || compareCodeUnits(left.parent, right.parent))) {
1107
+ let chunk = [];
1108
+ for (const row of rows.sort((left, right) => compareCodeUnits(left.path, right.path))) {
1109
+ const candidate = [...chunk, row];
1110
+ if (!groupFrameBounded(parent, candidate)) {
1111
+ if (chunk.length === 0 || !groupFrameBounded(parent, [row]))
1112
+ return undefined;
1113
+ logical.push({ parent, phase, mutations: chunk });
1114
+ chunk = [row];
1115
+ }
1116
+ else
1117
+ chunk = candidate;
1118
+ }
1119
+ if (chunk.length > 0)
1120
+ logical.push({ parent, phase, mutations: chunk });
1121
+ }
1122
+ const clusters = new Map();
1123
+ for (const group of logical) {
1124
+ const anchor = group.mutations.every(row => row.compactAnchor === group.mutations[0]?.compactAnchor)
1125
+ ? group.mutations[0]?.compactAnchor : undefined;
1126
+ if (!anchor)
1127
+ continue;
1128
+ const key = `${group.phase}\0${anchor}`;
1129
+ const cluster = clusters.get(key) ?? [];
1130
+ cluster.push(group);
1131
+ clusters.set(key, cluster);
1132
+ }
1133
+ const compacted = new Set();
1134
+ const result = [];
1135
+ for (const group of logical) {
1136
+ const anchor = group.mutations.every(row => row.compactAnchor === group.mutations[0]?.compactAnchor)
1137
+ ? group.mutations[0]?.compactAnchor : undefined;
1138
+ const key = anchor ? `${group.phase}\0${anchor}` : undefined;
1139
+ const cluster = key ? clusters.get(key) : undefined;
1140
+ if (!key || !cluster || compacted.has(key)) {
1141
+ if (!key || !cluster)
1142
+ result.push({ ...group, logicalWeight: 1 });
1143
+ continue;
1144
+ }
1145
+ compacted.add(key);
1146
+ const parents = new Set(cluster.map(row => row.parent));
1147
+ const entries = cluster.flatMap(row => row.mutations).sort((left, right) => compareCodeUnits(left.path, right.path));
1148
+ if (parents.size > 1 && groupFrameBounded(anchor, entries)) {
1149
+ result.push({ parent: anchor, phase: group.phase, mutations: entries, logicalWeight: 1 });
1150
+ }
1151
+ else
1152
+ for (const row of cluster)
1153
+ result.push({ ...row, logicalWeight: 1 });
1154
+ }
1155
+ result.sort((left, right) => phaseOrder[left.phase] - phaseOrder[right.phase] || compareCodeUnits(left.parent, right.parent));
1156
+ const merged = [];
1157
+ for (const group of result) {
1158
+ const prior = merged.at(-1);
1159
+ const mutations = prior && prior.phase === group.phase && prior.parent === group.parent
1160
+ ? [...prior.mutations, ...group.mutations].sort((left, right) => compareCodeUnits(left.path, right.path)) : undefined;
1161
+ if (prior && mutations && groupFrameBounded(group.parent, mutations)) {
1162
+ merged[merged.length - 1] = { ...prior, mutations, logicalWeight: prior.logicalWeight + group.logicalWeight };
1163
+ }
1164
+ else
1165
+ merged.push(group);
1166
+ }
1167
+ return merged.map(({ phase: _phase, ...group }) => group);
1168
+ }
1169
+ async function prepareBatch(root, rootIdentity, groups, files, staged) {
1170
+ const nonce = randomBytes(16).toString('hex');
1171
+ const rows = [];
1172
+ let inputs = [];
1173
+ for (const group of groups.slice(0, MAX_PUBLICATION_BATCH_GROUPS)) {
1174
+ const anchor = await publicationAnchor(root, group.parent);
1175
+ if (!anchor)
1176
+ return undefined;
1177
+ const path = group.parent === '.' ? root : resolve(root, group.parent);
1178
+ const groupInputs = [...inputs];
1179
+ const entries = [];
1180
+ for (const mutation of group.mutations) {
1181
+ const segments = publicationEntrySegments(group.parent, mutation.path);
1182
+ const nested = segments && await nestedPublicationAuthority(path, anchor.segments.length === 0, segments);
1183
+ if (!nested)
1184
+ return undefined;
1185
+ let input = null;
1186
+ if (mutation.operation === 'write-file') {
1187
+ input = groupInputs.length;
1188
+ groupInputs.push(staged[files.findIndex(file => file.path === mutation.path)]);
1189
+ }
1190
+ entries.push({ chain: nested.chain, segments: nested.segments,
1191
+ name: basename(mutation.path), operation: mutation.operation, expected: mutation.expected,
1192
+ sha256: mutation.sha256 ?? null, target: mutation.target ?? null, input });
1193
+ }
1194
+ const request = { nonce: randomBytes(16).toString('hex'), parent: group.parent, anchor: anchor.chain,
1195
+ identity: anchor.identity, segments: anchor.segments, entries };
1196
+ const candidate = { version: 1, nonce, action: 'prepare-batch', identity: rootIdentity,
1197
+ groups: [...rows.map(row => row.request), request] };
1198
+ if (helperFrameBytes(candidate) > MAX_HELPER_FRAME_BYTES)
1199
+ break;
1200
+ rows.push({ group, anchor, path, request });
1201
+ inputs = groupInputs;
1202
+ }
1203
+ return rows.length > 0 ? {
1204
+ request: { version: 1, nonce, action: 'prepare-batch', identity: rootIdentity,
1205
+ groups: rows.map(row => row.request) },
1206
+ rows,
1207
+ inputs,
1208
+ } : undefined;
1209
+ }
1210
+ async function compensate(groups, node, adapters, transactionDeadline) {
1211
+ const rollback = [...groups].reverse();
1212
+ let remainingWeight = rollback.reduce((count, group) => count + group.logicalWeight, 0);
1213
+ for (const [index, group] of rollback.entries()) {
1214
+ const nonce = randomBytes(16).toString('hex');
1215
+ const request = { version: 1, nonce, action: 'rollback', identity: group.identity, start: group.start, chain: group.chain, entries: group.entries };
1216
+ remainingWeight -= group.logicalWeight;
1217
+ const deadline = transactionDeadline - remainingWeight * PUBLICATION_HELPER_SLOT_MS;
1218
+ const receipt = await runHelper(adapters, helperStep(node, group.path, group.parent, request, [], deadline));
1219
+ if (!parseRollbackReceipt(receipt, nonce))
1220
+ return false;
1221
+ }
1222
+ return true;
1223
+ }
1224
+ function staleRemovalRequest(prepared, mutations) {
1225
+ const entries = [];
1226
+ for (const mutation of mutations.filter((candidate) => candidate.phase === 'stale')) {
1227
+ const current = mutation.expected;
1228
+ if (current.kind !== 'file' && current.kind !== 'symlink' || !current.identity
1229
+ || mutation.operation !== 'write-file' && mutation.operation !== 'remove-file' && mutation.operation !== 'remove-symlink')
1230
+ return undefined;
1231
+ entries.push({
1232
+ path: mutation.path,
1233
+ operation: mutation.operation,
1234
+ current: {
1235
+ kind: current.kind,
1236
+ ...(current.sha256 ? { sha256: current.sha256 } : {}),
1237
+ ...(current.target ? { target: current.target } : {}),
1238
+ dev: current.identity.dev,
1239
+ ino: current.identity.ino,
1240
+ },
1241
+ ...(mutation.sha256 ? { resultSha256: mutation.sha256 } : {}),
1242
+ });
1243
+ }
1244
+ if (entries.length === 0)
1245
+ return undefined;
1246
+ const payload = {
1247
+ format: 'cockpit-stale-removal-request/v1',
1248
+ cockpitId: prepared.cockpitId,
1249
+ generation: prepared.generation,
1250
+ sourceSha256: prepared.sourceSha256,
1251
+ entries: entries.sort((left, right) => compareCodeUnits(left.path, right.path)),
1252
+ };
1253
+ return { ...payload, digest: sha256Hex(JSON.stringify(payload)) };
1254
+ }
1255
+ async function staleRemovalApproved(request, adapters) {
1256
+ if (!adapters.authorizeStaleRemoval)
1257
+ return false;
1258
+ try {
1259
+ const approval = ownRecord(await adapters.authorizeStaleRemoval(request));
1260
+ return !!approval
1261
+ && exactKeys(approval, ['format', 'digest'])
1262
+ && approval.format === 'cockpit-stale-removal-approval/v1'
1263
+ && approval.digest === request.digest;
1264
+ }
1265
+ catch {
1266
+ // a throwing approval authority is no approval; the caller refuses.
1267
+ return false;
1268
+ }
1269
+ }
1270
+ function sameExpected(left, right) {
1271
+ return left.kind === right.kind && left.sha256 === right.sha256 && left.target === right.target
1272
+ && left.identity?.dev === right.identity?.dev && left.identity?.ino === right.identity?.ino;
1273
+ }
1274
+ function sameIdentity(left, right) {
1275
+ return left.root.dev === right.root.dev && left.root.ino === right.root.ino
1276
+ && left.file.dev === right.file.dev && left.file.ino === right.file.ino
1277
+ && left.chain.length === right.chain.length
1278
+ && left.chain.every((row, index) => row.dev === right.chain[index]?.dev && row.ino === right.chain[index]?.ino);
1279
+ }
1280
+ function exactPath(root, path) {
1281
+ return path.length <= 4096 && !/[\\\x00-\x1f\x7f*?\[\]{}]/.test(path)
1282
+ && path.split('/').every(part => safePublicationSegment(part)
1283
+ // Folding to upper case first also catches spellings that only fold onto a reserved
1284
+ // name, such as a dotless i or long s, on case-insensitive filesystems.
1285
+ && !reservedPathSegment(part.normalize('NFC').toUpperCase().normalize('NFC'))) && confined(root, path) !== undefined;
1286
+ }
1287
+ function classifyEntry(fact, current) {
1288
+ if (!fact)
1289
+ return undefined;
1290
+ const entry = fact.entry;
1291
+ const observation = current.kind === 'file' ? { path: entry.path, kind: 'file', bytes: current.bytes,
1292
+ identity: JSON.stringify(current.identity), confinement: 'confined' }
1293
+ : { path: entry.path, kind: current.kind, confinement: current.kind === 'missing' ? 'confined' : 'unknown' };
1294
+ const classified = classifyGeneratedPaths([fact], [observation]);
1295
+ return classified.kind === 'complete' ? classified.paths[0]?.state : undefined;
1296
+ }
1297
+ async function admitBootstrapPlan(input, adapters) {
1298
+ const supplied = ownRecord(input);
1299
+ const refreshInput = supplied && 'refresh' in supplied ? ownRecord(supplied.refresh) : undefined;
1300
+ if (!supplied || !exactKeys(supplied, refreshInput
1301
+ ? ['mode', 'root', 'rootIdentity', 'node', 'inspected', 'mutations', 'refresh']
1302
+ : ['mode', 'root', 'rootIdentity', 'node', 'inspected', 'mutations']) || supplied.mode !== 'bootstrap'
1303
+ || !isInspectedBootstrapOutputs(supplied.inspected)) {
1304
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap publication input is not an exact bounded plan', 'generated-environment');
1305
+ }
1306
+ const serializable = snapshotGeneratedJson({ mode: supplied.mode, root: supplied.root, rootIdentity: supplied.rootIdentity,
1307
+ node: supplied.node, mutations: supplied.mutations });
1308
+ if (!serializable || serializable.mode !== 'bootstrap' || !isAbsolute(serializable.root) || resolve(serializable.root) !== serializable.root
1309
+ || !directoryIdentity(serializable.rootIdentity)
1310
+ || !serializable.node || !isAbsolute(serializable.node.canonicalPath) || !SHA256.test(serializable.node.sha256)
1311
+ || !Array.isArray(serializable.mutations) || serializable.mutations.length > MAX_OWNED_FILES) {
1312
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap publication input is not an exact bounded plan', 'generated-environment');
1313
+ }
1314
+ const { node } = serializable;
1315
+ const inspected = supplied.inspected;
1316
+ const desired = inspected.files.map(file => ({
1317
+ kind: 'file', path: file.path, harnesses: [file.target], scope: 'always',
1318
+ capability: 'instructed', writer: 'cockpit', schemaVersion: 1,
1319
+ ownership: 'whole-file', bytes: file.bytes, sha256: file.sha256,
1320
+ }));
1321
+ const mutations = [...serializable.mutations];
1322
+ if (mutations.length > desired.length || new Set(mutations.map(row => row.path)).size !== mutations.length
1323
+ || mutations.some(row => !exactPath(serializable.root, row.path) || row.phase !== 'desired' || row.operation !== 'write-file'
1324
+ || typeof row.bytes !== 'string' || row.sha256 !== sha256Hex(row.bytes))) {
1325
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap publication contains an undeclared or non-exact operation', 'generated-environment');
1326
+ }
1327
+ const paths = desired.map(entry => entry.path);
1328
+ if (!portablePublicationPaths(paths) || mutations.some(row => !paths.includes(row.path))) {
1329
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap publication operation is outside the selected target output set', 'generated-environment');
1330
+ }
1331
+ let prior;
1332
+ if (refreshInput) {
1333
+ if (!exactKeys(refreshInput, ['address', 'commandSha256', 'revision']) || !Number.isSafeInteger(refreshInput.revision)
1334
+ || refreshInput.revision < 0 || !SHA256.test(refreshInput.commandSha256)) {
1335
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap refresh authority is not exact', 'generated-environment');
1336
+ }
1337
+ const observed = await readState(refreshInput.address);
1338
+ const record = observed.kind === 'complete' ? observed.value : undefined;
1339
+ if (!record)
1340
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap refresh state could not be authenticated', 'generated-environment');
1341
+ const priorPreviewSha256 = sha256Hex(JSON.stringify({ target: record.target, sources: record.value.sources,
1342
+ files: record.value.published }));
1343
+ if (record.revision !== refreshInput.revision || record.value.commandSha256 !== refreshInput.commandSha256
1344
+ || record.value.previewSha256 !== priorPreviewSha256 || record.value.stage !== 'surface-published'
1345
+ || record.value.checkoutRoot !== serializable.root
1346
+ || record.target !== inspected.target || record.value.target !== inspected.target
1347
+ || JSON.stringify(record.value.published.map(row => row.path)) !== JSON.stringify(paths)) {
1348
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap refresh authority does not match the fixed output set', 'generated-environment');
1349
+ }
1350
+ prior = record.value.published;
1351
+ }
1352
+ const reads = await readAnchoredBatches(serializable.root, [...paths.map(path => ({ path, action: 'read-entry', maxBytes: MAX_GENERATED_BYTES })),
1353
+ { path: OWNERSHIP_PATH, action: 'read-entry', maxBytes: MAX_OWNERSHIP_BYTES },
1354
+ ...(prior ? [{ path: BOOTSTRAP_CONFIGURATION_PATH, action: 'read-entry', maxBytes: MAX_GENERATED_BYTES }] : [])], node, adapters);
1355
+ if (!reads || reads.kind !== 'complete' || reads.results.length !== paths.length + 1 + (prior ? 1 : 0)) {
1356
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap destinations could not be classified safely', 'generated-environment');
1357
+ }
1358
+ const ledger = reads.results[paths.length];
1359
+ if (ledger.kind !== 'missing')
1360
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'bootstrap publication cannot replace ownership metadata', OWNERSHIP_PATH);
1361
+ const configuration = prior && reads.results[paths.length + 1];
1362
+ if (configuration && configuration.kind !== 'missing')
1363
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap refresh requires configuration to remain absent', BOOTSTRAP_CONFIGURATION_PATH);
1364
+ const checks = [];
1365
+ for (const [index, entry] of desired.entries()) {
1366
+ if (entry.kind !== 'file')
1367
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap output is not a whole file', entry.path);
1368
+ const current = reads.results[index];
1369
+ if (current.kind === 'complete' || current.kind === 'invalid' || current.kind === 'other' || current.kind === 'symlink') {
1370
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap destination is not an anchored regular file or missing', entry.path);
1371
+ }
1372
+ const expected = expectedState(current);
1373
+ if (!expected)
1374
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap destination state is unknown', entry.path);
1375
+ const mutation = mutations.find(row => row.path === entry.path);
1376
+ if (mutation && (!sameExpected(expected, mutation.expected) || mutation.bytes !== entry.bytes || mutation.sha256 !== entry.sha256)) {
1377
+ return refuse('GENERATED_SOURCE_CHANGED', 'bootstrap destination or postimage changed after classification', entry.path);
1378
+ }
1379
+ const priorEntry = prior?.[index];
1380
+ const refreshCurrent = priorEntry?.path === entry.path && current.kind === 'file'
1381
+ && (current.sha256 === priorEntry.sha256 || current.sha256 === entry.sha256);
1382
+ const refreshMutation = refreshCurrent && current.sha256 === priorEntry.sha256 && priorEntry.sha256 !== entry.sha256;
1383
+ if (prior
1384
+ ? !refreshCurrent || refreshMutation !== (mutation !== undefined)
1385
+ : current.kind === 'missing' ? !mutation : current.kind !== 'file' || current.sha256 !== entry.sha256 || mutation !== undefined) {
1386
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap plan must write each missing output and preserve each exact output', entry.path);
1387
+ }
1388
+ checks.push({ path: entry.path, expected, maxBytes: current.kind === 'file' ? Math.max(1, current.bytes.byteLength) : 1,
1389
+ ...(current.kind === 'file' ? { identity: current.identity } : {}) });
1390
+ }
1391
+ checks.push({ path: OWNERSHIP_PATH, expected: { kind: 'missing' }, maxBytes: 1 });
1392
+ if (prior)
1393
+ checks.push({ path: BOOTSTRAP_CONFIGURATION_PATH,
1394
+ expected: { kind: 'missing' }, maxBytes: 1 });
1395
+ const groups = groupMutations(mutations);
1396
+ const count = groups?.reduce((total, group) => total + group.logicalWeight, 0) ?? 0;
1397
+ if (!groups || count > desired.length || count * PUBLICATION_HELPER_CALLS_PER_GROUP * PUBLICATION_HELPER_SLOT_MS > TRANSACTION_TIMEOUT_MS) {
1398
+ return refuse('GENERATED_PUBLICATION_FAILED', 'bootstrap publication exceeds the bounded helper schedule', 'generated-environment');
1399
+ }
1400
+ const ordered = groups.flatMap(group => group.mutations);
1401
+ const changes = ordered.map(row => ({ operation: 'write', path: row.path }));
1402
+ const plan = { changes, record: { root: serializable.root, rootIdentity: serializable.rootIdentity, node,
1403
+ mutations: ordered, groups, changes, checks } };
1404
+ return { kind: 'complete', plan };
1405
+ }
1406
+ async function planCurrent(record, adapters, deadline, published = false) {
1407
+ const checks = published ? record.checks.filter(row => !record.mutations.some(mutation => mutation.path === row.path)) : record.checks;
1408
+ const reads = await readAnchoredBatches(record.root, checks.map(row => ({ path: row.path, action: 'read-entry', maxBytes: row.maxBytes })), record.node, adapters, deadline);
1409
+ return reads?.kind === 'complete' && reads.results.length === checks.length && reads.results.every((read, index) => {
1410
+ if (read.kind === 'complete')
1411
+ return false;
1412
+ const expected = checks[index];
1413
+ const state = expectedState(read);
1414
+ return state !== undefined && sameExpected(state, expected.expected)
1415
+ && (!expected.identity || read.kind === 'file' && sameIdentity(read.identity, expected.identity));
1416
+ });
1417
+ }
1418
+ /** Admit exact classified leaves against the observed durable ledger, never raw delete authority. */
1419
+ export async function admitPublicationPlan(input, adapters) {
1420
+ try {
1421
+ return await admitPlan(input, adapters);
1422
+ }
1423
+ catch (error) {
1424
+ return refuse('GENERATED_PUBLICATION_FAILED', `publication input, ownership or target observation could not be authenticated: ${errorCause(error)}`, 'generated-environment');
1425
+ }
1426
+ }
1427
+ async function admitPlan(input, adapters) {
1428
+ const supplied = ownRecord(input);
1429
+ if (supplied?.mode === 'bootstrap')
1430
+ return admitBootstrapPlan(input, adapters);
1431
+ const captured = snapshotGeneratedJson(supplied);
1432
+ const fields = ownRecord(captured);
1433
+ if (!captured || !fields || !exactKeys(fields, ['authority', 'generation', 'sourceSha256', 'rootIdentity', 'node', 'mode', 'ledgerExpected', 'nextLedgerBytes', 'mutations',
1434
+ ...(Object.hasOwn(fields, 'surfaceActions') ? ['surfaceActions'] : [])])
1435
+ || captured.mode !== 'sync'
1436
+ || !captured.authority || !isAbsolute(captured.authority.root)
1437
+ || dirname(captured.authority.configPath) !== captured.authority.root
1438
+ || !SHA256.test(captured.generation) || !SHA256.test(captured.sourceSha256)
1439
+ || !directoryIdentity(captured.rootIdentity) || !Array.isArray(captured.mutations) || captured.mutations.length > MAX_OWNED_FILES
1440
+ || !captured.node || !isAbsolute(captured.node.canonicalPath) || !SHA256.test(captured.node.sha256)) {
1441
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication input is not a bounded exact plan', 'generated-environment');
1442
+ }
1443
+ const { authority, node } = captured;
1444
+ const ledger = await readAnchoredEntry(authority.root, OWNERSHIP_PATH, MAX_OWNERSHIP_BYTES, node, adapters);
1445
+ const ledgerState = expectedState(ledger);
1446
+ if (!ledgerState || !captured.ledgerExpected || !sameExpected(ledgerState, captured.ledgerExpected)) {
1447
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'generated ownership metadata changed after observation', OWNERSHIP_PATH);
1448
+ }
1449
+ let prior;
1450
+ if (ledger.kind === 'file') {
1451
+ let parsed;
1452
+ try {
1453
+ parsed = parseOwnershipManifest(new TextDecoder('utf-8', { fatal: true }).decode(ledger.bytes), authority);
1454
+ }
1455
+ catch { /* Invalid bytes refuse below. */ }
1456
+ if (!parsed || parsed.kind !== 'complete')
1457
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'publication requires authenticated ownership metadata', OWNERSHIP_PATH);
1458
+ prior = parsed.manifest;
1459
+ }
1460
+ else if (ledger.kind !== 'missing') {
1461
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'generated ownership metadata is not a regular file', OWNERSHIP_PATH);
1462
+ }
1463
+ const parsedNext = typeof captured.nextLedgerBytes === 'string' ? parseOwnershipManifest(captured.nextLedgerBytes, authority) : undefined;
1464
+ if (parsedNext?.kind !== 'complete' || parsedNext.manifest.cockpitId !== authority.cockpitId || parsedNext.manifest.generation !== captured.generation
1465
+ || parsedNext.manifest.source.sha256 !== captured.sourceSha256)
1466
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'publication ledger does not match its declared authority', OWNERSHIP_PATH);
1467
+ const next = parsedNext.manifest;
1468
+ const priorProjection = prior && projectGeneratedPathFacts(prior, authority, []);
1469
+ if (priorProjection && priorProjection.kind !== 'complete')
1470
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'publication requires authenticated prior ownership facts', OWNERSHIP_PATH);
1471
+ const nextProjection = projectGeneratedPathFacts(next, authority, []);
1472
+ if (nextProjection.kind !== 'complete')
1473
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'publication requires authenticated resulting ownership facts', OWNERSHIP_PATH);
1474
+ const priorFacts = new Map(priorProjection?.facts.map(fact => [fact.path, fact]));
1475
+ const nextFacts = new Map(nextProjection.facts.map(fact => [fact.path, fact]));
1476
+ // Only an unchanged authenticated estate can authorize restoration of corrupt bytes.
1477
+ const restorationEstate = prior?.format === 'cockpit-generated-ownership/v3'
1478
+ && next.format === 'cockpit-generated-ownership/v3' && prior.cockpitId === next.cockpitId
1479
+ && prior.generation === next.generation && prior.source.path === next.source.path && prior.source.sha256 === next.source.sha256
1480
+ && prior.producer !== null && next.producer !== null && prior.producer.package === next.producer.package
1481
+ && prior.producer.version === next.producer.version && prior.producer.sourceRevision === next.producer.sourceRevision
1482
+ && isDeepStrictEqual(prior.entries, next.entries);
1483
+ const mutations = [...captured.mutations];
1484
+ if (!portablePublicationPaths(mutations.map(row => row.path)))
1485
+ return refuse('GENERATED_PUBLICATION_FAILED', 'generated output names alias or cannot be transferred between supported filesystems; rename the declared outputs and rerun npx --no cockpit sync --config <your cockpit.toml> --dry-run', 'generated-environment');
1486
+ if (new Set(mutations.map(row => row.path)).size !== mutations.length
1487
+ || mutations.some(row => !exactPath(authority.root, row.path) || row.path === OWNERSHIP_PATH || row.phase === 'ledger'
1488
+ || !['desired', 'stale'].includes(row.phase) || !['write-file', 'write-symlink', 'remove-file', 'remove-symlink'].includes(row.operation))) {
1489
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication contains an undeclared or non-exact operation', 'generated-environment');
1490
+ }
1491
+ const paths = [...new Set([...(prior?.entries ?? []).map(row => row.path), ...next.entries.map(row => row.path)])].sort(compareCodeUnits);
1492
+ if (!portablePublicationPaths(paths))
1493
+ return refuse('GENERATED_PUBLICATION_FAILED', 'generated output names alias or cannot be transferred between supported filesystems; rename the declared outputs and rerun npx --no cockpit sync --config <your cockpit.toml> --dry-run', 'generated-environment');
1494
+ if (mutations.some(row => !paths.includes(row.path)))
1495
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication operation is absent from both ownership ledgers', 'generated-environment');
1496
+ // Sizes allocate bounded batches; anchored reads still authenticate every target and refuse growth.
1497
+ const maxBytes = await Promise.all(paths.map(async (path) => {
1498
+ if (!exactPath(authority.root, path))
1499
+ return undefined;
1500
+ try {
1501
+ const status = await lstat(join(authority.root, path));
1502
+ if (!status.isFile() || status.isSymbolicLink())
1503
+ return 1;
1504
+ if (!Number.isSafeInteger(status.size) || status.size < 0)
1505
+ return undefined;
1506
+ return Math.min(MAX_GENERATED_BYTES, Math.max(1, status.size));
1507
+ }
1508
+ catch (error) {
1509
+ return hasErrorCode(error, 'ENOENT') ? 1 : undefined;
1510
+ }
1511
+ }));
1512
+ if (maxBytes.some(limit => limit === undefined))
1513
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication targets could not be classified safely', 'generated-environment');
1514
+ const reads = await readAnchoredBatches(authority.root, paths.map((path, index) => ({ path, action: 'read-entry', maxBytes: maxBytes[index] })), node, adapters);
1515
+ if (!reads || reads.kind !== 'complete' || reads.results.length !== paths.length)
1516
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication targets could not be classified safely', 'generated-environment');
1517
+ const checks = [];
1518
+ for (const [index, path] of paths.entries()) {
1519
+ const current = reads.results[index];
1520
+ if (current.kind === 'complete')
1521
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication observation has the wrong kind', path);
1522
+ const expected = expectedState(current);
1523
+ if (!expected)
1524
+ return refuse('GENERATED_PUBLICATION_FAILED', 'publication target kind is unknown', path);
1525
+ const previous = prior?.entries.find(row => row.path === path);
1526
+ const desired = next.entries.find(row => row.path === path);
1527
+ const priorFact = priorFacts.get(path);
1528
+ const nextFact = nextFacts.get(path);
1529
+ if (previous && !priorFact || desired && !nextFact)
1530
+ return refuse('GENERATED_OWNERSHIP_INVALID', 'publication target lacks authenticated ownership facts', path);
1531
+ const mutation = mutations.find(row => row.path === path);
1532
+ if (mutation && !sameExpected(expected, mutation.expected))
1533
+ return refuse('GENERATED_SOURCE_CHANGED', 'publication target changed after classification', path);
1534
+ const priorState = previous && classifyEntry(priorFact, current);
1535
+ const restoration = restorationEstate && priorState === 'modified' && current.kind === 'file'
1536
+ && previous?.kind === 'file' && desired?.kind === 'file' && mutation?.phase === 'desired'
1537
+ && mutation.operation === 'write-file' && mutation.sha256 === previous.sha256;
1538
+ if (previous && current.kind !== 'missing' && priorState !== 'unchanged' && !restoration)
1539
+ return refuse('GENERATED_STALE_ARTIFACT_DRIFT', 'owned target has changed or its confinement is unknown', path);
1540
+ if (!mutation) {
1541
+ if (desired ? classifyEntry(nextFact, current) !== 'unchanged' : current.kind !== 'missing') {
1542
+ return refuse('GENERATED_PUBLICATION_FAILED', 'complete publication plan omitted a required operation', path);
1543
+ }
1544
+ }
1545
+ else if (!desired) {
1546
+ if (!previous || priorState !== 'unchanged' || mutation.phase !== 'stale')
1547
+ return refuse('GENERATED_PUBLICATION_FAILED', 'destructive operation lacks exact classified prior ownership', path);
1548
+ if (previous.kind === 'shared-region' && current.kind === 'file') {
1549
+ const removed = removeSharedOwnership(new TextDecoder('utf-8', { fatal: true }).decode(current.bytes), previous, prior);
1550
+ if (removed.kind !== 'complete' || mutation.operation !== 'write-file' || mutation.bytes !== removed.bytes
1551
+ || mutation.sha256 !== sha256Hex(removed.bytes))
1552
+ return refuse('GENERATED_PUBLICATION_FAILED', 'shared removal must preserve the exact foreign postimage', path);
1553
+ }
1554
+ else if (mutation.operation !== 'remove-file') {
1555
+ return refuse('GENERATED_PUBLICATION_FAILED', 'removal does not name the exact owned leaf kind', path);
1556
+ }
1557
+ }
1558
+ else {
1559
+ if (mutation.phase !== 'desired' || mutation.operation.startsWith('remove-') || !previous && current.kind !== 'missing' && desired.kind !== 'shared-region') {
1560
+ return refuse('GENERATED_PUBLICATION_FAILED', 'write destination lacks applicable ownership', path);
1561
+ }
1562
+ if (mutation.operation !== 'write-file' || typeof mutation.bytes !== 'string' || Buffer.byteLength(mutation.bytes) > MAX_GENERATED_BYTES
1563
+ || mutation.sha256 !== sha256Hex(mutation.bytes))
1564
+ return refuse('GENERATED_PUBLICATION_FAILED', 'file publication has invalid bounded bytes', path);
1565
+ const post = { kind: 'file', bytes: Buffer.from(mutation.bytes), sha256: mutation.sha256, identity: current.kind === 'file' ? current.identity
1566
+ : { root: captured.rootIdentity, chain: [], file: captured.rootIdentity }, mode: 0o600 };
1567
+ if (classifyEntry(nextFact, post) !== 'unchanged')
1568
+ return refuse('GENERATED_PUBLICATION_FAILED', 'write postimage differs from its declared ownership', path);
1569
+ if (desired.kind === 'shared-region') {
1570
+ const before = current.kind === 'file' ? new TextDecoder('utf-8', { fatal: true }).decode(current.bytes) : desired.region.format === 'opencode-json-root-properties' ? '{}' : '';
1571
+ const action = captured.surfaceActions?.find(row => row.path === path);
1572
+ if (!action || action.kind !== 'write' || action.desired.kind !== 'shared-region')
1573
+ return refuse('GENERATED_PUBLICATION_FAILED', 'shared write has no matching planned surface write', path);
1574
+ // A deleted shared file has no foreign bytes left to protect, so it is recreated from an
1575
+ // empty base exactly as the surface planner did; the prior region would demand bytes that are gone.
1576
+ const rendered = renderSharedPostimage(before, action.desired, current.kind === 'missing' ? undefined : previous);
1577
+ if (rendered.kind !== 'complete' || rendered.bytes !== mutation.bytes || action.postimage !== mutation.bytes)
1578
+ return refuse('GENERATED_PUBLICATION_FAILED', 'shared write would change foreign bytes', path);
1579
+ }
1580
+ }
1581
+ checks.push({ path, expected, maxBytes: current.kind === 'file' ? Math.max(1, current.bytes.byteLength) : 1, ...(current.kind === 'file' ? { identity: current.identity } : {}) });
1582
+ }
1583
+ checks.push({ path: OWNERSHIP_PATH, expected: ledgerState, maxBytes: ledger.kind === 'file' ? Math.max(1, ledger.bytes.byteLength) : 1, ...(ledger.kind === 'file' ? { identity: ledger.identity } : {}) });
1584
+ if (ledger.kind !== 'file' || ledger.sha256 !== sha256Hex(captured.nextLedgerBytes))
1585
+ mutations.push({ path: OWNERSHIP_PATH, phase: 'ledger', operation: 'write-file',
1586
+ bytes: captured.nextLedgerBytes, sha256: sha256Hex(captured.nextLedgerBytes), expected: ledgerState });
1587
+ const groups = groupMutations(mutations);
1588
+ const count = groups?.reduce((total, group) => total + group.logicalWeight, 0) ?? 0;
1589
+ if (!groups || count > MAX_OWNED_FILES + 1 || count * PUBLICATION_HELPER_CALLS_PER_GROUP * PUBLICATION_HELPER_SLOT_MS > TRANSACTION_TIMEOUT_MS) {
1590
+ return refuse('GENERATED_PUBLICATION_FAILED', 'generated publication exceeds the bounded helper schedule', 'generated-environment');
1591
+ }
1592
+ const ordered = groups.flatMap(group => group.mutations);
1593
+ const staleRemoval = staleRemovalRequest({ cockpitId: authority.cockpitId, generation: captured.generation, sourceSha256: captured.sourceSha256 }, ordered);
1594
+ if (ordered.some(row => row.phase === 'stale') && !staleRemoval)
1595
+ return refuse('GENERATED_STALE_APPROVAL_INVALID', 'stale operations cannot be bound to exact current identities', 'generated-environment');
1596
+ const changes = ordered.map(row => ({ operation: row.operation.startsWith('remove-') ? 'remove' : 'write', path: row.path }));
1597
+ const plan = { changes, ...(staleRemoval ? { staleRemoval } : {}),
1598
+ record: { root: authority.root, rootIdentity: captured.rootIdentity, node, mutations: ordered, groups, changes, checks, ...(staleRemoval ? { staleRemoval } : {}) } };
1599
+ return { kind: 'complete', plan };
1600
+ }
1601
+ function publicationApproved(plan, adapters) {
1602
+ const { staleRemoval } = plan.record;
1603
+ plan.approval ??= Promise.resolve().then(() => staleRemoval ? staleRemovalApproved(staleRemoval, adapters) : true);
1604
+ return plan.approval;
1605
+ }
1606
+ function approvalRefusal(record) {
1607
+ return {
1608
+ kind: 'refusal', artifact: null, destination: null, result: null, status: 2,
1609
+ diagnostics: [{
1610
+ severity: 'error',
1611
+ code: 'GENERATED_STALE_APPROVAL_REQUIRED',
1612
+ message: `stale generated ownership requires independent approval bound to the exact current entries (stale-removal digest ${record.staleRemoval.digest})`,
1613
+ target: record.staleRemoval.entries[0].path,
1614
+ fix: 'a person runs npx --no cockpit sync --config <your cockpit.toml> --interactive in a terminal outside CI, reviews the stale entries it lists, and types the digest it shows only if every removal is intended',
1615
+ }],
1616
+ };
1617
+ }
1618
+ /** Asks for approval before another owner writes; the run reuses this answer instead of asking again. */
1619
+ export async function authorizePublicationPlan(plan, adapters) {
1620
+ const admitted = plan;
1621
+ return await publicationApproved(admitted, adapters) ? true : approvalRefusal(admitted.record);
1622
+ }
1623
+ export async function runPublicationPlan(plan, adapters) {
1624
+ try {
1625
+ return await runPlan(plan, adapters);
1626
+ }
1627
+ catch (error) {
1628
+ return refuse('GENERATED_PUBLICATION_FAILED', `publication owner revalidation failed: ${errorCause(error)}`, 'generated-environment');
1629
+ }
1630
+ }
1631
+ async function runPlan(plan, adapters) {
1632
+ const admitted = plan;
1633
+ const { record } = admitted;
1634
+ if (adapters.dryRun) {
1635
+ const current = await adapters.revalidate(undefined, 'preview');
1636
+ if (current !== true)
1637
+ return typeof current === 'object' ? current : refuse('GENERATED_SOURCE_CHANGED', 'publication preview inputs changed', 'generated-environment');
1638
+ if (!await planCurrent(record, adapters))
1639
+ return refuse('GENERATED_SOURCE_CHANGED', 'publication preview identities changed', 'generated-environment');
1640
+ return { kind: 'complete', changes: record.changes, committedPaths: [], ...(record.staleRemoval ? { staleRemoval: record.staleRemoval } : {}) };
1641
+ }
1642
+ if (!await publicationApproved(admitted, adapters))
1643
+ return approvalRefusal(record);
1644
+ return applyPrepared(record, adapters);
1645
+ }
1646
+ async function applyPrepared(prepared, adapters) {
1647
+ const { mutations: publicationMutations, groups } = prepared;
1648
+ const publicationFiles = publicationMutations.filter(row => row.operation === 'write-file')
1649
+ .map(row => ({ path: row.path, bytes: row.bytes, sha256: row.sha256 }));
1650
+ const logicalGroupCount = groups.reduce((count, group) => count + group.logicalWeight, 0);
1651
+ const transactionDeadline = Date.now() + TRANSACTION_TIMEOUT_MS;
1652
+ const forwardCutoff = transactionDeadline - logicalGroupCount * PUBLICATION_HELPER_SLOT_MS;
1653
+ let forwardCallsRemaining = logicalGroupCount * (PUBLICATION_HELPER_CALLS_PER_GROUP - 1);
1654
+ const nextForwardDeadline = (calls = 1) => {
1655
+ if (!Number.isSafeInteger(calls) || calls < 1 || calls > forwardCallsRemaining)
1656
+ return undefined;
1657
+ forwardCallsRemaining -= calls;
1658
+ return forwardCutoff - forwardCallsRemaining * PUBLICATION_HELPER_SLOT_MS;
1659
+ };
1660
+ const node = prepared.node;
1661
+ if (publicationMutations.length === 0) {
1662
+ const transactionRootCurrent = async () => {
1663
+ const current = await lstat(prepared.root);
1664
+ return current.isDirectory() && !current.isSymbolicLink()
1665
+ && current.dev === prepared.rootIdentity.dev && current.ino === prepared.rootIdentity.ino;
1666
+ };
1667
+ if (Date.now() >= forwardCutoff || !await transactionRootCurrent()) {
1668
+ return refuse('GENERATED_SOURCE_CHANGED', 'generated transaction root changed or its deadline expired', 'generated-environment');
1669
+ }
1670
+ // Without writes, final canonical validation and exact admitted identities suffice.
1671
+ const current = await adapters.revalidate(forwardCutoff, 'after-publish');
1672
+ if (current !== true)
1673
+ return typeof current === 'object' ? current : refuse('GENERATED_SOURCE_CHANGED', 'generated inputs changed during unchanged publication', 'generated-environment');
1674
+ if (Date.now() >= forwardCutoff || !await planCurrent(prepared, adapters, forwardCutoff)
1675
+ || !await transactionRootCurrent() || Date.now() >= forwardCutoff) {
1676
+ return refuse('GENERATED_SOURCE_CHANGED', 'generated inputs or target identities changed during unchanged publication', 'generated-environment');
1677
+ }
1678
+ return { kind: 'complete', changes: prepared.changes, committedPaths: [] };
1679
+ }
1680
+ const staged = await stageInputs(publicationFiles);
1681
+ if (!node || !staged)
1682
+ return refuse('GENERATED_PUBLICATION_FAILED', 'generated publication authority could not be prepared', 'generated-environment');
1683
+ const applied = [];
1684
+ let retainInputs = false;
1685
+ try {
1686
+ const transactionRoot = await lstat(prepared.root);
1687
+ const transactionRootIdentity = prepared.rootIdentity;
1688
+ if (!transactionRoot.isDirectory() || transactionRoot.isSymbolicLink()
1689
+ || transactionRoot.dev !== transactionRootIdentity.dev || transactionRoot.ino !== transactionRootIdentity.ino) {
1690
+ throw new RefusalError('generated transaction root is unsafe');
1691
+ }
1692
+ const transactionRootCurrent = async () => {
1693
+ const current = await lstat(prepared.root);
1694
+ return current.isDirectory() && !current.isSymbolicLink()
1695
+ && current.dev === transactionRootIdentity.dev && current.ino === transactionRootIdentity.ino;
1696
+ };
1697
+ if (Date.now() >= forwardCutoff)
1698
+ throw new RefusalError('generated helper schedule exhausted');
1699
+ const beforeStage = await adapters.revalidate(forwardCutoff, 'before-stage');
1700
+ if (beforeStage !== true)
1701
+ return typeof beforeStage === 'object' ? beforeStage : refuse('GENERATED_SOURCE_CHANGED', 'generated inputs changed before staging', 'generated-environment');
1702
+ if (!await planCurrent(prepared, adapters, forwardCutoff)) {
1703
+ return refuse('GENERATED_SOURCE_CHANGED', 'generated inputs or target identities changed before staging', 'generated-environment');
1704
+ }
1705
+ for (const mutation of publicationMutations)
1706
+ if (!confined(prepared.root, mutation.path)) {
1707
+ return refuse('GENERATED_PATH_UNSAFE', 'generated destination is not confined', mutation.path);
1708
+ }
1709
+ // One helper call prepares as many groups as one request frame holds. Prepare never changes a
1710
+ // target, so a run interrupted inside a batch leaves only stage and backup files beside them.
1711
+ for (let next = 0; next < groups.length;) {
1712
+ if (!await transactionRootCurrent())
1713
+ throw new RefusalError('generated transaction root changed');
1714
+ const batch = await prepareBatch(prepared.root, transactionRootIdentity, groups.slice(next), publicationFiles, staged.inputs);
1715
+ const deadline = batch && nextForwardDeadline(batch.rows.reduce((count, row) => count + row.group.logicalWeight, 0));
1716
+ if (!batch || deadline === undefined)
1717
+ throw new RefusalError('generated destination parent is unsafe or its helper schedule is exhausted');
1718
+ const raw = await runHelper(adapters, helperStep(node, prepared.root, 'generated-environment', batch.request, batch.inputs, deadline, MAX_HELPER_FRAME_BYTES * (batch.rows.length + 1)));
1719
+ const receipt = parsePrepareBatchReceipt(raw, batch);
1720
+ if (receipt && receipt.groups.length > 0) {
1721
+ applied.push(...receipt.groups);
1722
+ if (!await recordRecovery(staged.directory, applied))
1723
+ throw new RefusalError('generated recovery record could not be persisted');
1724
+ }
1725
+ if (!receipt?.complete)
1726
+ throw new RefusalError('generated filesystem helper refused publication');
1727
+ if (!await transactionRootCurrent())
1728
+ throw new RefusalError('generated transaction root changed');
1729
+ next += batch.rows.length;
1730
+ }
1731
+ if (Date.now() >= forwardCutoff)
1732
+ throw new RefusalError('generated helper schedule exhausted');
1733
+ const inputsCurrent = await adapters.revalidate(forwardCutoff, 'before-publish');
1734
+ if (inputsCurrent !== true || !await planCurrent(prepared, adapters, forwardCutoff) || !await transactionRootCurrent()) {
1735
+ if (!await compensate(applied, node, adapters, transactionDeadline)) {
1736
+ retainInputs = true;
1737
+ return refuse('GENERATED_COMPENSATION_FAILED', 'generated inputs changed after preparation and retained backups could not be safely restored; recover retained local backups before retrying', `${applied.at(-1)?.parent ?? 'generated-environment'}; inputs=${staged.directory}`);
1738
+ }
1739
+ return typeof inputsCurrent === 'object' ? inputsCurrent : refuse('GENERATED_SOURCE_CHANGED', 'a generated input changed immediately before publication', 'generated-environment');
1740
+ }
1741
+ if (await runPublicationStage('publish', prepared.root, transactionRootIdentity, applied, node, adapters, nextForwardDeadline) !== 'complete')
1742
+ throw new RefusalError('generated filesystem helper did not confirm publication');
1743
+ if (Date.now() >= forwardCutoff)
1744
+ throw new RefusalError('generated helper schedule exhausted');
1745
+ if (await adapters.revalidate(forwardCutoff, 'after-publish') !== true || !await planCurrent(prepared, adapters, forwardCutoff, true)) {
1746
+ throw new RefusalError('generated input or unchanged target changed during publication');
1747
+ }
1748
+ // A postcheck failure before any backup was retired still rolls back. Retirement can be
1749
+ // partial, so any later or unknown failure retains recovery instead of rolling back.
1750
+ const finalized = await runPublicationStage('commit', prepared.root, transactionRootIdentity, applied, node, adapters, nextForwardDeadline).catch(() => 'failed');
1751
+ if (finalized === 'verify-failed')
1752
+ throw new RefusalError('generated postcheck failed');
1753
+ if (finalized !== 'complete' || forwardCallsRemaining !== 0) {
1754
+ retainInputs = true;
1755
+ return refuse('GENERATED_COMPENSATION_FAILED', 'generated bytes were published but retained backups could not be safely finalized; recover retained local backups before retrying', `${applied.at(-1)?.parent ?? 'generated-environment'}; inputs=${staged.directory}`);
1756
+ }
1757
+ return { kind: 'complete', changes: prepared.changes, committedPaths: prepared.changes.map(row => row.path) };
1758
+ }
1759
+ catch (error) {
1760
+ if (!await compensate(applied, node, adapters, transactionDeadline)) {
1761
+ retainInputs = true;
1762
+ return refuse('GENERATED_COMPENSATION_FAILED', `generated publication failed (${errorCause(error)}) and prior bytes could not be fully restored; recover retained local backups before retrying`, `${applied.at(-1)?.parent ?? 'generated-environment'}; inputs=${staged.directory}`);
1763
+ }
1764
+ return refuse('GENERATED_PUBLICATION_FAILED', `generated files were not published (filesystem transaction refused: ${errorCause(error)})`, 'generated-environment');
1765
+ }
1766
+ finally {
1767
+ if (!retainInputs)
1768
+ await rm(staged.directory, { recursive: true, force: true });
1769
+ }
1770
+ }