@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,2153 @@
1
+ import { access, link, lstat, mkdir, open, opendir, readdir, realpath, rename, rm, rmdir, unlink } from 'node:fs/promises';
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { isProxy } from 'node:util/types';
7
+ import { z } from 'zod';
8
+ import { loadCockpitConfig } from '../config/load.js';
9
+ import { STRUCTURED_INPUT_ENTRY_LIMIT } from '../schema.js';
10
+ import { ANSWER_PROVENANCES } from '../init/render.js';
11
+ import { SURFACE_TARGETS } from '../surfaces/contract.js';
12
+ import { checkoutId as deriveCheckoutId } from './identity.js';
13
+ import { redactSensitiveText } from './sensitive.js';
14
+ import { sha256Hex } from '../sha256.js';
15
+ import { hasErrorCode, ENVIRONMENT_CODES, errorCause, RefusalError, snapshotEnvironmentObservation, snapshotJson, isCanonicalTimestamp, } from './contract.js';
16
+ const OWNER = 'environment-state';
17
+ export const observationDeclarations = Object.freeze([
18
+ { id: 'config-freshness', owner: OWNER, postures: ['environment'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.CONFIG_STALE, isIssued: isStateObservation, issuedAt: observationIssuedAt },
19
+ { id: 'config-schema-validity', owner: OWNER, postures: ['environment'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, isIssued: isStateObservation, issuedAt: observationIssuedAt },
20
+ ]);
21
+ const issuedObservations = new WeakMap();
22
+ const issuedNamespaces = new WeakSet();
23
+ const issuedNamespacePaths = new WeakMap();
24
+ const issuedBootstrapNamespaces = new WeakSet();
25
+ const issuedScopedLocks = new WeakMap();
26
+ const issuedBootstrapAnswerResets = new WeakSet();
27
+ const issuedBootstrapObservationRefreshes = new WeakSet();
28
+ const issuedBootstrapPublicationRefreshes = new WeakSet();
29
+ const issuedBootstrapRestarts = new WeakSet();
30
+ function issueObservation(value) {
31
+ const snapshot = snapshotEnvironmentObservation(value);
32
+ if (!snapshot || snapshot.owner !== OWNER)
33
+ throw new RefusalError('state observation is malformed');
34
+ issuedObservations.set(snapshot, Date.now());
35
+ return snapshot;
36
+ }
37
+ export function isStateObservation(value) {
38
+ return value !== null && typeof value === 'object' && issuedObservations.has(value);
39
+ }
40
+ function observationIssuedAt(value) {
41
+ return value !== null && typeof value === 'object' ? issuedObservations.get(value) : undefined;
42
+ }
43
+ export async function observeConfiguration(input) {
44
+ const snapshot = snapshotJson(input);
45
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
46
+ throw new RefusalError('configuration observation input is malformed');
47
+ const owned = snapshot;
48
+ if (typeof owned.path !== 'string' || owned.path.length === 0 || typeof owned.stackId !== 'string'
49
+ || !/^stack:[a-f0-9]{64}$/.test(owned.stackId) || typeof owned.observedAt !== 'string'
50
+ || !isCanonicalTimestamp(owned.observedAt))
51
+ throw new RefusalError('configuration observation input is malformed');
52
+ const loaded = await loadCockpitConfig(owned.path);
53
+ const failure = loaded.kind === 'refusal' ? loaded.diagnostics[0] : undefined;
54
+ const source = loaded.kind === 'complete' ? loaded.result.source.path : failure?.target ?? owned.path;
55
+ return [
56
+ issueObservation({
57
+ code: ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, owner: OWNER, stackId: owned.stackId, source,
58
+ observed: failure
59
+ ? { state: 'failed', reason: failure.message, observedAt: owned.observedAt }
60
+ : { state: 'available', value: true, observedAt: owned.observedAt },
61
+ expectation: { kind: 'equal', value: true }, applicability: { state: 'required', posture: 'environment' }, freshness: { state: 'current' },
62
+ remedy: 'repair the configuration before evaluating the environment',
63
+ }),
64
+ issueObservation({
65
+ code: ENVIRONMENT_CODES.CONFIG_STALE, owner: OWNER, stackId: owned.stackId, source,
66
+ observed: loaded.kind === 'complete'
67
+ ? { state: 'available', value: loaded.result.source.sha256, observedAt: owned.observedAt }
68
+ : { state: 'unavailable', reason: 'configuration revision is unavailable', observedAt: owned.observedAt },
69
+ expectation: loaded.kind === 'complete' ? { kind: 'equal', value: loaded.result.source.sha256 } : { kind: 'present' },
70
+ applicability: { state: 'required', posture: 'environment' }, freshness: { state: 'current' }, remedy: 'regenerate from the current configuration revision',
71
+ }),
72
+ ];
73
+ }
74
+ const STATE_FORMAT = 'cockpit-state/v1';
75
+ export const SERIALIZED_RECORD_BYTE_LIMIT = 1_048_576;
76
+ const RECORD_KINDS = ['coordination', 'lifecycle', 'lifecycle-history', 'data-outcome', 'cache'];
77
+ const safeSegment = /^[a-z0-9][a-z0-9._-]*$/;
78
+ const digest = /^[a-f0-9]{64}$/;
79
+ const cockpitIdentity = /^cockpit:[a-z0-9][a-z0-9-]*:[a-f0-9]{64}$/;
80
+ const checkoutIdentity = /^checkout:[a-f0-9]{64}$/;
81
+ const stackIdentity = /^stack:[a-f0-9]{64}$/;
82
+ const holderIdentity = /^holder:[a-f0-9]{64}:session$/;
83
+ const bootstrapHolderIdentity = /^bootstrap-holder:[a-f0-9]{64}:session$/;
84
+ const lockOwnerIdentity = /^(?:holder:[a-f0-9]{64}:session|bootstrap-holder:[a-f0-9]{64}:session)$/;
85
+ const surfaceTargets = new Set(SURFACE_TARGETS);
86
+ // The setup skill is at most three files per tool, published for every listed tool.
87
+ const BOOTSTRAP_PUBLISHED_MAX = 3 * SURFACE_TARGETS.length;
88
+ const bootstrapStages = ['surface-previewed', 'surface-published', 'draft-prepared', 'draft-observed', 'environment-verified'];
89
+ const BootstrapPathSchema = z.string().min(1).max(4_096)
90
+ .regex(/^(?!\/)(?!.*\\)(?!.*[\x00-\x1f\x7f])[^/]+(?:\/[^/]+)*$/)
91
+ .refine(path => path.split('/').every(segment => segment !== '.' && segment !== '..'));
92
+ const StateRecordSchema = z.object({
93
+ format: z.literal(STATE_FORMAT),
94
+ kind: z.enum(RECORD_KINDS),
95
+ cockpitId: z.string().min(1),
96
+ subjectId: z.string().min(1),
97
+ revision: z.number().int().nonnegative(),
98
+ updatedAt: z.string().refine(isCanonicalTimestamp),
99
+ value: z.json(),
100
+ }).strict();
101
+ const StateLockSchema = z.object({
102
+ format: z.literal(STATE_FORMAT),
103
+ kind: z.literal('state-lock'),
104
+ ownerId: z.string().regex(lockOwnerIdentity),
105
+ pid: z.number().int().positive(),
106
+ acquiredAt: z.string().refine(isCanonicalTimestamp),
107
+ token: z.string().uuid(),
108
+ }).strict();
109
+ const BootstrapDigestRowSchema = z.object({
110
+ path: BootstrapPathSchema,
111
+ sha256: z.string().regex(digest),
112
+ }).strict();
113
+ const GreenfieldQuestionSchema = z.object({
114
+ id: z.string().regex(/^question:[a-f0-9]{64}$/),
115
+ field: z.string().min(1).max(256),
116
+ source: z.string().min(1).max(4_096),
117
+ why: z.string().min(1).max(512),
118
+ expectedSha256: z.string().regex(digest).optional(),
119
+ }).strict();
120
+ const GreenfieldAnswerSchema = z.object({
121
+ questionId: z.string().regex(/^question:[a-f0-9]{64}$/),
122
+ bytes: z.string().min(1).max(262_144)
123
+ .refine(value => Buffer.byteLength(value, 'utf8') <= 262_144)
124
+ .refine(value => value.trim() === value && !/[\x00-\x1f\x7f]/.test(value) && redactSensitiveText(value) === value),
125
+ sha256: z.string().regex(digest),
126
+ provenance: z.enum(ANSWER_PROVENANCES),
127
+ }).strict();
128
+ const GreenfieldStateSchema = z.object({
129
+ mode: z.enum(['estate', 'registry']).optional(),
130
+ stage: z.enum(['question-pending', 'answers-recorded', 'resolved']),
131
+ classification: z.enum(['one-sibling', 'incomplete-multi-repository', 'no-runnable-product', 'ready-multi-repository']).optional(),
132
+ sourceObservationSha256: z.string().regex(digest),
133
+ notice: z.string().min(1).max(1_024).optional(),
134
+ questions: z.array(GreenfieldQuestionSchema).max(64),
135
+ answers: z.array(GreenfieldAnswerSchema).max(64),
136
+ }).strict().superRefine((value, context) => {
137
+ if (new Set(value.questions.map(row => row.id)).size !== value.questions.length
138
+ || new Set(value.answers.map(row => row.questionId)).size !== value.answers.length
139
+ || value.answers.some((answer, index) => answer.questionId !== value.questions[index]?.id
140
+ || createHash('sha256').update(answer.bytes).digest('hex') !== answer.sha256)) {
141
+ context.addIssue({ code: 'custom', message: 'greenfield answer provenance is malformed' });
142
+ }
143
+ const complete = value.answers.length === value.questions.length;
144
+ if ((value.stage === 'question-pending' && complete)
145
+ || (value.stage === 'answers-recorded' && (!complete || value.questions.length === 0))
146
+ || (value.stage === 'resolved' && (value.questions.length !== 0 || value.answers.length !== 0))) {
147
+ context.addIssue({ code: 'custom', message: 'greenfield answers do not match the interview stage' });
148
+ }
149
+ if ((value.mode ?? 'estate') === 'estate' && (!value.classification || !value.notice)) {
150
+ context.addIssue({ code: 'custom', message: 'estate interview provenance is incomplete' });
151
+ }
152
+ if (value.mode === 'registry' && (value.classification || value.notice)) {
153
+ context.addIssue({ code: 'custom', message: 'registry interview cannot claim estate classification provenance' });
154
+ }
155
+ if (value.stage === 'resolved' && value.classification === 'incomplete-multi-repository') {
156
+ context.addIssue({ code: 'custom', message: 'an incomplete estate cannot be resolved without recorded answers' });
157
+ }
158
+ });
159
+ const BootstrapStateRecordSchema = z.object({
160
+ format: z.literal(STATE_FORMAT),
161
+ kind: z.literal('bootstrap'),
162
+ checkoutId: z.string().regex(checkoutIdentity),
163
+ target: z.enum(SURFACE_TARGETS),
164
+ revision: z.number().int().nonnegative(),
165
+ updatedAt: z.string().refine(isCanonicalTimestamp),
166
+ value: z.object({
167
+ stage: z.enum(bootstrapStages),
168
+ checkoutRoot: z.string().min(1).max(4_096),
169
+ target: z.enum(SURFACE_TARGETS),
170
+ // Entry point plus shared procedure for every shipped skill; a new shipped skill must not need a state bump.
171
+ sources: z.array(BootstrapDigestRowSchema).min(1).max(16),
172
+ commandSha256: z.string().regex(digest),
173
+ previewSha256: z.string().regex(digest),
174
+ published: z.array(BootstrapDigestRowSchema).max(BOOTSTRAP_PUBLISHED_MAX),
175
+ destination: BootstrapPathSchema.nullable(),
176
+ artifactBytes: z.string().max(262_144).nullable(),
177
+ artifactSha256: z.string().regex(digest).nullable(),
178
+ greenfield: GreenfieldStateSchema.optional(),
179
+ }).strict(),
180
+ }).strict().superRefine((record, context) => {
181
+ const value = record.value;
182
+ if (value.target !== record.target || !isAbsolute(value.checkoutRoot) || resolve(value.checkoutRoot) !== value.checkoutRoot) {
183
+ context.addIssue({ code: 'custom', message: 'bootstrap record identity is malformed' });
184
+ }
185
+ if (new Set(value.sources.map(row => row.path)).size !== value.sources.length
186
+ || new Set(value.published.map(row => row.path)).size !== value.published.length) {
187
+ context.addIssue({ code: 'custom', message: 'bootstrap record paths are duplicated' });
188
+ }
189
+ if ((value.stage === 'surface-previewed') !== (value.published.length === 0)) {
190
+ context.addIssue({ code: 'custom', message: 'bootstrap publication rows do not match the stage' });
191
+ }
192
+ const draftRequired = ['draft-prepared', 'draft-observed', 'environment-verified'].includes(value.stage);
193
+ const draftFields = [value.destination, value.artifactBytes, value.artifactSha256];
194
+ if (draftRequired !== draftFields.every(field => field !== null)
195
+ || !draftRequired && draftFields.some(field => field !== null)) {
196
+ context.addIssue({ code: 'custom', message: 'bootstrap draft fields do not match the stage' });
197
+ }
198
+ if (value.artifactBytes !== null && value.artifactSha256 !== null
199
+ && createHash('sha256').update(value.artifactBytes).digest('hex') !== value.artifactSha256) {
200
+ context.addIssue({ code: 'custom', message: 'bootstrap draft digest does not match its bytes' });
201
+ }
202
+ });
203
+ export function abortStateTransaction(value) {
204
+ return { aborted: true, value };
205
+ }
206
+ const issuedStatePublications = new WeakMap();
207
+ function issueStatePublication(result, address) {
208
+ if (!('kind' in result) || result.kind !== 'complete' && result.kind !== 'indeterminate')
209
+ return result;
210
+ const record = snapshotJson(result.value);
211
+ if (!record || typeof record !== 'object' || Array.isArray(record)
212
+ || !StateRecordSchema.safeParse(record).success && !BootstrapStateRecordSchema.safeParse(record).success)
213
+ return result;
214
+ const issued = { ...result };
215
+ issuedStatePublications.set(issued, Object.freeze({
216
+ status: result.kind,
217
+ address: Object.freeze({ ...address }),
218
+ record: record,
219
+ }));
220
+ return issued;
221
+ }
222
+ export function authenticateStatePublication(input) {
223
+ try {
224
+ if (input === null || typeof input !== 'object' || Array.isArray(input) || isProxy(input)
225
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(input)))
226
+ return undefined;
227
+ const keys = Reflect.ownKeys(input);
228
+ const descriptors = Object.getOwnPropertyDescriptors(input);
229
+ if (keys.length !== 2 || !keys.includes('publication') || !keys.includes('address')
230
+ || ['publication', 'address'].some((key) => {
231
+ const descriptor = descriptors[key];
232
+ return descriptor === undefined || !descriptor.enumerable || !('value' in descriptor);
233
+ }))
234
+ return undefined;
235
+ const publication = descriptors.publication.value;
236
+ if (publication === null || typeof publication !== 'object' || isProxy(publication))
237
+ return undefined;
238
+ const issued = issuedStatePublications.get(publication);
239
+ const address = snapshotJson(descriptors.address.value);
240
+ if (!issued || !address || typeof address !== 'object' || Array.isArray(address))
241
+ return undefined;
242
+ const ownedAddress = address;
243
+ const expectedAddress = issued.address;
244
+ const addressKeys = Reflect.ownKeys(ownedAddress);
245
+ const expectedAddressKeys = Reflect.ownKeys(expectedAddress);
246
+ if (addressKeys.length !== expectedAddressKeys.length
247
+ || expectedAddressKeys.some((key) => typeof key !== 'string'
248
+ || ownedAddress[key] !== expectedAddress[key]))
249
+ return undefined;
250
+ return { status: issued.status, record: issued.record };
251
+ }
252
+ catch {
253
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
254
+ return undefined;
255
+ }
256
+ }
257
+ // A proposed record never carries `aborted`, so the marker needs no registry to tell it apart.
258
+ function isStateTransactionAbort(value) {
259
+ return typeof value === 'object' && value !== null && value.aborted === true;
260
+ }
261
+ function refusal(code, message, target, recovery) {
262
+ return { kind: 'refusal', code, message, target, ...(recovery ? { recovery } : {}) };
263
+ }
264
+ function confinedTo(parent, candidate) {
265
+ const relation = relative(resolve(parent), resolve(candidate));
266
+ return relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..' && !isAbsolute(relation));
267
+ }
268
+ // Directory write access lets another user replace otherwise private authority records.
269
+ function privatelyOwned(status, uid = process.getuid?.()) {
270
+ return uid !== undefined && status.uid === uid && (status.mode & 0o022) === 0;
271
+ }
272
+ async function unusableDirectory(path, uid) {
273
+ let status;
274
+ try {
275
+ status = await lstat(path);
276
+ }
277
+ catch (error) {
278
+ if (hasErrorCode(error, 'ENOENT'))
279
+ return 'missing';
280
+ return { reason: `${path} cannot be inspected (${errorCause(error)})`, denied: deniedAccess(error) };
281
+ }
282
+ if (status.isSymbolicLink())
283
+ return { reason: `${path} is a symbolic link`, denied: false };
284
+ if (!status.isDirectory())
285
+ return { reason: `${path} is not a directory`, denied: false };
286
+ if (!privatelyOwned(status, uid))
287
+ return { reason: `${path} is not owned by this user or is writable by its group or others`, denied: false };
288
+ if ((status.mode & 0o200) === 0)
289
+ return { reason: `${path} is not writable by its owner`, denied: false };
290
+ try {
291
+ await access(path, constants.W_OK);
292
+ }
293
+ catch (error) {
294
+ return { reason: `${path} is not writable by this process (${errorCause(error)})`, denied: deniedAccess(error) };
295
+ }
296
+ return undefined;
297
+ }
298
+ function deniedAccess(error) {
299
+ return hasErrorCode(error, 'EPERM', 'EACCES', 'EROFS');
300
+ }
301
+ async function usableBase(path, uid) {
302
+ if (!isAbsolute(path))
303
+ return { reason: `${path} is not an absolute path`, denied: false };
304
+ const unusable = await unusableDirectory(path, uid);
305
+ return unusable === 'missing' ? { reason: `${path} does not exist`, denied: false } : unusable;
306
+ }
307
+ async function usableSelectedBase(path, uid) {
308
+ if (!isAbsolute(path))
309
+ return { reason: `${path} is not an absolute path`, denied: false };
310
+ let cursor = path;
311
+ while (true) {
312
+ const unusable = await unusableDirectory(cursor, uid);
313
+ if (unusable !== 'missing')
314
+ return unusable;
315
+ const parent = dirname(cursor);
316
+ if (parent === cursor)
317
+ return { reason: `no ancestor of ${path} exists`, denied: false };
318
+ cursor = parent;
319
+ }
320
+ }
321
+ async function safeDescendant(base, candidate, uid) {
322
+ const unusableBase = await usableSelectedBase(base, uid);
323
+ if (unusableBase)
324
+ return unusableBase;
325
+ if (!confinedTo(base, candidate))
326
+ return { reason: `${candidate} is outside ${base}`, denied: false };
327
+ const relation = relative(base, candidate);
328
+ let cursor = base;
329
+ for (const segment of relation.split(sep).filter(Boolean)) {
330
+ cursor = join(cursor, segment);
331
+ const unusable = await unusableDirectory(cursor, uid);
332
+ if (unusable === 'missing')
333
+ break;
334
+ if (unusable)
335
+ return unusable;
336
+ }
337
+ return undefined;
338
+ }
339
+ // The refusal names the directory and the failed check. A permission failure is usually a sandbox
340
+ // (Codex's workspace-write sandbox keeps the home directory read-only); cockpit cannot run inside
341
+ // one that forbids its state root, so it says to run the command outside it.
342
+ function unusableStateRoot(root, unusable) {
343
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `cockpit keeps its state under ${root}, but ${unusable.reason}${unusable.denied
344
+ ? '; a sandbox that keeps this directory read-only cannot run cockpit commands that record state, so run this command outside the sandbox or let the sandbox write there'
345
+ : ''}`, root);
346
+ }
347
+ function checkedSegment(value, label) {
348
+ if (!safeSegment.test(value))
349
+ throw new RefusalError(`${label} is not a safe state path segment`);
350
+ return value;
351
+ }
352
+ function checkedIdentity(value, pattern, label) {
353
+ if (!pattern.test(value))
354
+ throw new RefusalError(`${label} is not a canonical environment identity`);
355
+ return value;
356
+ }
357
+ function identitySegment(identity) {
358
+ // Canonical identities contain no underscores, so this preserves identity without Windows stream syntax.
359
+ return identity.replaceAll(':', '_');
360
+ }
361
+ function checkedDigest(value) {
362
+ if (!digest.test(value))
363
+ throw new RefusalError('cache identity digest must be 64 lowercase hexadecimal characters');
364
+ return value;
365
+ }
366
+ function address(path, stateRoot, cockpitRoot, kind, cockpitId, subjectId, qualifier) {
367
+ return { path, stateRoot, cockpitRoot, kind, cockpitId, subjectId, ...(qualifier ? { qualifier } : {}) };
368
+ }
369
+ function namespace(root, cockpitId) {
370
+ const id = checkedIdentity(cockpitId, cockpitIdentity, 'cockpit id');
371
+ const cockpitRoot = join(root, 'cockpits', identitySegment(id));
372
+ const stackAddress = (kind, stackId) => {
373
+ const subjectId = checkedIdentity(stackId, stackIdentity, 'stack id');
374
+ return address(join(cockpitRoot, 'records', kind, `${identitySegment(subjectId)}.json`), root, cockpitRoot, kind, cockpitId, subjectId);
375
+ };
376
+ const paths = {
377
+ coordination: (stackId) => stackAddress('coordination', stackId),
378
+ lifecycle: (stackId) => stackAddress('lifecycle', stackId),
379
+ dataOutcome: (stackId) => stackAddress('data-outcome', stackId),
380
+ stateLock: join(cockpitRoot, 'locks', 'state.lock'),
381
+ installLock: (cacheId) => join(cockpitRoot, 'locks', 'install', `cache-${checkedSegment(cacheId, 'cache id')}.lock`),
382
+ cache: (checkoutId, cacheIdentitySha256) => {
383
+ const subjectId = checkedIdentity(checkoutId, checkoutIdentity, 'checkout id');
384
+ const checksum = checkedDigest(cacheIdentitySha256);
385
+ return address(join(cockpitRoot, 'cache', identitySegment(subjectId), `${checksum}.json`), root, cockpitRoot, 'cache', cockpitId, subjectId, checksum);
386
+ },
387
+ };
388
+ const value = { root, cockpitRoot, paths };
389
+ issuedNamespaces.add(value);
390
+ issuedNamespacePaths.set(paths.coordination, value);
391
+ return value;
392
+ }
393
+ function bootstrapNamespace(root, checkoutRoot, checkoutId, selectedTarget) {
394
+ const id = checkedIdentity(checkoutId, checkoutIdentity, 'checkout id');
395
+ const scopedRoot = join(root, 'checkouts', identitySegment(id));
396
+ const bootstrap = (target) => {
397
+ if (!surfaceTargets.has(target) || target !== selectedTarget)
398
+ throw new RefusalError('surface target does not match the selected bootstrap target');
399
+ return {
400
+ path: join(scopedRoot, 'records', 'bootstrap', `${target}.json`),
401
+ stateRoot: root,
402
+ checkoutRoot,
403
+ kind: 'bootstrap',
404
+ checkoutId: id,
405
+ target,
406
+ };
407
+ };
408
+ const value = {
409
+ root,
410
+ checkoutRoot,
411
+ checkoutId: id, target: selectedTarget,
412
+ paths: { bootstrap, stateLock: join(scopedRoot, 'locks', 'state.lock') },
413
+ };
414
+ issuedBootstrapNamespaces.add(value);
415
+ return value;
416
+ }
417
+ export function authenticateStateNamespace(value) {
418
+ return value !== null && typeof value === 'object' && issuedNamespaces.has(value)
419
+ ? value
420
+ : undefined;
421
+ }
422
+ function authenticateBootstrapStateNamespace(value) {
423
+ return value !== null && typeof value === 'object' && issuedBootstrapNamespaces.has(value)
424
+ ? value
425
+ : undefined;
426
+ }
427
+ export function issueStateScopedLock(namespace, input) {
428
+ const authenticated = authenticateStateNamespace(namespace);
429
+ const captured = snapshotJson(input);
430
+ if (!authenticated || !captured || Object.keys(captured).sort().join(',') !== 'identity,owner'
431
+ || typeof captured.owner !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(captured.owner)
432
+ || typeof captured.identity !== 'string' || !digest.test(captured.identity))
433
+ return undefined;
434
+ const scope = { kind: 'state-scoped-lock' };
435
+ issuedScopedLocks.set(scope, Object.freeze({ namespace: authenticated,
436
+ path: join(authenticated.cockpitRoot, 'locks', 'scoped', `${captured.owner}-${captured.identity}`, 'state.lock') }));
437
+ return scope;
438
+ }
439
+ export async function withStateRootPortLock(value, options, operation) {
440
+ const authenticated = authenticateStateNamespace(value);
441
+ if (!authenticated)
442
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'root port lock requires an issued state namespace', 'root port lock');
443
+ const scope = { kind: 'state-scoped-lock' };
444
+ issuedScopedLocks.set(scope, Object.freeze({ namespace: authenticated, path: join(authenticated.root, 'locks', 'ports', 'state.lock') }));
445
+ return withStateScopedLock(authenticated, scope, options, operation);
446
+ }
447
+ class InventoryBoundaryError extends RefusalError {
448
+ }
449
+ // The callback only reads coordination records. Revalidate the entire read set before returning:
450
+ // a per-cockpit scan alone misses a replacement made after that cockpit has already been visited.
451
+ // Only the caller's own namespace must be readable. Another cockpit's namespace that this version
452
+ // cannot read is skipped and handed to the callback by path: refusing on it let one stale
453
+ // directory left by an earlier build block every cockpit on the machine.
454
+ export async function withStateNamespaceInventory(value, operation) {
455
+ const authenticated = authenticateStateNamespace(value);
456
+ if (!authenticated || typeof operation !== 'function') {
457
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'state namespace inventory requires issued authority and a read operation', 'state namespace inventory');
458
+ }
459
+ const snapshots = new Map();
460
+ const namespaces = [];
461
+ const skipped = [];
462
+ let entries = 0;
463
+ let bytes = 0n;
464
+ let target = join(authenticated.root, 'cockpits');
465
+ const inspect = async (path, kind, optional = false) => {
466
+ target = path;
467
+ let status;
468
+ try {
469
+ status = await lstat(path, { bigint: true });
470
+ }
471
+ catch (error) {
472
+ if (!optional || !hasErrorCode(error, 'ENOENT'))
473
+ throw error;
474
+ snapshots.set(path, undefined);
475
+ const parent = dirname(path);
476
+ if (!snapshots.has(parent))
477
+ await inspect(parent, 'directory', true);
478
+ return false;
479
+ }
480
+ if (status.isSymbolicLink() || (kind === 'directory' ? !status.isDirectory() : !status.isFile())
481
+ || status.uid !== BigInt(process.getuid?.() ?? -1) || (status.mode & 18n) !== 0n) {
482
+ throw new RefusalError('state inventory entry is not safely owned');
483
+ }
484
+ snapshots.set(path, status);
485
+ return true;
486
+ };
487
+ const names = async (path) => {
488
+ target = path;
489
+ const found = [];
490
+ const handle = await opendir(path);
491
+ for await (const entry of handle) {
492
+ if (++entries > STRUCTURED_INPUT_ENTRY_LIMIT)
493
+ throw new InventoryBoundaryError('state inventory exceeds its aggregate entry boundary');
494
+ found.push(entry.name);
495
+ }
496
+ return found.sort();
497
+ };
498
+ try {
499
+ await inspect(authenticated.root, 'directory', true);
500
+ const directory = join(authenticated.root, 'cockpits');
501
+ if (await inspect(directory, 'directory', true)) {
502
+ const own = basename(authenticated.cockpitRoot);
503
+ for (const name of await names(directory)) {
504
+ const path = join(directory, name);
505
+ const visited = new Set(snapshots.keys());
506
+ try {
507
+ const id = name.replaceAll('_', ':');
508
+ target = path;
509
+ if (!cockpitIdentity.test(id) || identitySegment(id) !== name)
510
+ throw new RefusalError('state inventory contains an invalid cockpit namespace');
511
+ const candidate = namespace(authenticated.root, id);
512
+ await inspect(candidate.cockpitRoot, 'directory');
513
+ const records = join(candidate.cockpitRoot, 'records');
514
+ const coordination = join(records, 'coordination');
515
+ await inspect(records, 'directory', true);
516
+ if (await inspect(coordination, 'directory', true)) {
517
+ for (const entry of await names(coordination)) {
518
+ const path = join(coordination, entry);
519
+ target = path;
520
+ const parsed = parseCoordinationStateEntry(entry);
521
+ if (!parsed)
522
+ throw new RefusalError('coordination store contains an unexpected entry');
523
+ await inspect(path, 'file');
524
+ if (parsed.kind === 'executor' && snapshots.get(path).nlink !== 1n) {
525
+ throw new RefusalError('coordination executor marker has multiple links');
526
+ }
527
+ bytes += snapshots.get(path).size;
528
+ if (bytes > BigInt(SERIALIZED_RECORD_BYTE_LIMIT))
529
+ throw new InventoryBoundaryError('coordination inventory exceeds its aggregate byte boundary');
530
+ }
531
+ }
532
+ namespaces.push(candidate);
533
+ }
534
+ catch (error) {
535
+ if (name === own || error instanceof InventoryBoundaryError)
536
+ throw error;
537
+ for (const key of snapshots.keys())
538
+ if (!visited.has(key))
539
+ snapshots.delete(key);
540
+ skipped.push({ path, reason: errorCause(error) });
541
+ }
542
+ }
543
+ }
544
+ }
545
+ catch (error) {
546
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state namespace inventory could not be read safely: ${errorCause(error)}`, target, 'inspect the named state entry and retry after all coordination writers finish');
547
+ }
548
+ const result = await operation(namespaces, skipped);
549
+ try {
550
+ for (const [path, before] of snapshots) {
551
+ target = path;
552
+ let after;
553
+ try {
554
+ after = await lstat(path, { bigint: true });
555
+ }
556
+ catch (error) {
557
+ if (!before && hasErrorCode(error, 'ENOENT'))
558
+ continue;
559
+ throw error;
560
+ }
561
+ if (!before || after.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino
562
+ || after.mode !== before.mode || after.uid !== before.uid || after.size !== before.size
563
+ || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
564
+ throw new RefusalError('state inventory changed during observation');
565
+ }
566
+ }
567
+ }
568
+ catch (error) {
569
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state namespace inventory changed during observation: ${errorCause(error)}`, target, 'retry after all coordination writers finish; inspect the named state entry if the refusal persists');
570
+ }
571
+ return { kind: 'complete', value: result };
572
+ }
573
+ export function lifecycleHistoryAddress(value, stackId) {
574
+ const authenticated = authenticateStateNamespace(value);
575
+ if (!authenticated)
576
+ return undefined;
577
+ const subjectId = checkedIdentity(stackId, stackIdentity, 'stack id');
578
+ const lifecycle = authenticated.paths.lifecycle(subjectId);
579
+ return address(join(authenticated.cockpitRoot, 'records', 'lifecycle-history', `${identitySegment(subjectId)}.json`), authenticated.root, authenticated.cockpitRoot, 'lifecycle-history', lifecycle.cockpitId, subjectId);
580
+ }
581
+ export function parseCoordinationStateEntry(name) {
582
+ if (/^\.stack_[a-f0-9]{64}\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test(name)) {
583
+ return { kind: 'temporary' };
584
+ }
585
+ const executor = /^(stack_[a-f0-9]{64})\.json\.([a-f0-9]{64})\.executor\.json$/.exec(name);
586
+ if (executor)
587
+ return { kind: 'executor', stackId: executor[1].replace('_', ':'), attemptId: executor[2] };
588
+ if (!/^stack_[a-f0-9]{64}\.json$/.test(name))
589
+ return undefined;
590
+ return { kind: 'record', stackId: name.slice(0, -5).replace('_', ':') };
591
+ }
592
+ export function snapshotStateNamespace(value) {
593
+ try {
594
+ const own = (object, key) => {
595
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
596
+ if (!descriptor || !('value' in descriptor))
597
+ throw new RefusalError('namespace properties must be owned data');
598
+ return descriptor.value;
599
+ };
600
+ const root = own(value, 'root');
601
+ const cockpitRoot = own(value, 'cockpitRoot');
602
+ const paths = own(value, 'paths');
603
+ if (typeof root !== 'string' || typeof cockpitRoot !== 'string' || !paths || typeof paths !== 'object')
604
+ return undefined;
605
+ const coordination = own(paths, 'coordination');
606
+ const lifecycle = own(paths, 'lifecycle');
607
+ const dataOutcome = own(paths, 'dataOutcome');
608
+ const stateLock = own(paths, 'stateLock');
609
+ const installLock = own(paths, 'installLock');
610
+ const cache = own(paths, 'cache');
611
+ if (typeof coordination !== 'function'
612
+ || typeof lifecycle !== 'function'
613
+ || typeof dataOutcome !== 'function'
614
+ || typeof stateLock !== 'string'
615
+ || typeof installLock !== 'function'
616
+ || typeof cache !== 'function')
617
+ return undefined;
618
+ // Recover captured authority only from its original path functions and exact namespace fields.
619
+ const issued = issuedNamespacePaths.get(coordination);
620
+ if (issued && root === issued.root && cockpitRoot === issued.cockpitRoot
621
+ && lifecycle === issued.paths.lifecycle && dataOutcome === issued.paths.dataOutcome
622
+ && stateLock === issued.paths.stateLock && installLock === issued.paths.installLock
623
+ && cache === issued.paths.cache)
624
+ return issued;
625
+ return {
626
+ root,
627
+ cockpitRoot,
628
+ paths: {
629
+ coordination,
630
+ lifecycle,
631
+ dataOutcome,
632
+ stateLock,
633
+ installLock,
634
+ cache,
635
+ },
636
+ };
637
+ }
638
+ catch {
639
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
640
+ return undefined;
641
+ }
642
+ }
643
+ export function snapshotBootstrapStateNamespace(value) {
644
+ try {
645
+ if (!authenticateBootstrapStateNamespace(value))
646
+ return undefined;
647
+ const root = Object.getOwnPropertyDescriptor(value, 'root')?.value;
648
+ const checkoutRoot = Object.getOwnPropertyDescriptor(value, 'checkoutRoot')?.value;
649
+ const checkoutId = Object.getOwnPropertyDescriptor(value, 'checkoutId')?.value;
650
+ const target = Object.getOwnPropertyDescriptor(value, 'target')?.value;
651
+ const paths = Object.getOwnPropertyDescriptor(value, 'paths')?.value;
652
+ if (typeof root !== 'string' || typeof checkoutRoot !== 'string' || typeof checkoutId !== 'string'
653
+ || typeof target !== 'string' || !surfaceTargets.has(target)
654
+ || !paths || typeof paths !== 'object')
655
+ return undefined;
656
+ const bootstrap = Object.getOwnPropertyDescriptor(paths, 'bootstrap')?.value;
657
+ const stateLock = Object.getOwnPropertyDescriptor(paths, 'stateLock')?.value;
658
+ if (typeof bootstrap !== 'function' || typeof stateLock !== 'string')
659
+ return undefined;
660
+ return { root, checkoutRoot, checkoutId, target,
661
+ paths: { bootstrap, stateLock } };
662
+ }
663
+ catch {
664
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
665
+ return undefined;
666
+ }
667
+ }
668
+ // `refusal` is typed as the whole result union; this narrows it for callers that only ever refuse.
669
+ const stateRefusal = (code, message, target) => refusal(code, message, target);
670
+ async function selectStateBase(input, uid) {
671
+ if (input.platform === 'linux' && input.xdgStateHome !== undefined) {
672
+ const root = join(input.xdgStateHome, 'cockpit', 'v1');
673
+ const unusable = await usableSelectedBase(input.xdgStateHome, uid);
674
+ if (unusable)
675
+ return unusableStateRoot(root, unusable);
676
+ return { kind: 'complete', value: { base: input.xdgStateHome, root } };
677
+ }
678
+ if (!input.home)
679
+ return stateRefusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'the state root is under the home directory, but HOME is not set', '');
680
+ const root = input.platform === 'darwin'
681
+ ? join(input.home, 'Library', 'Application Support', 'cockpit', 'state', 'v1')
682
+ : join(input.home, '.local', 'state', 'cockpit', 'v1');
683
+ const unusable = await usableBase(input.home, uid);
684
+ if (unusable)
685
+ return unusableStateRoot(root, unusable);
686
+ return { kind: 'complete', value: { base: input.home, root } };
687
+ }
688
+ /**
689
+ * The per-user state root, without a cockpit or checkout: what `runtime measure` needs to record a
690
+ * fact about this machine before any stack exists. It is the same root `selectStateRoot` scopes
691
+ * each cockpit under, so every checkout of every cockpit on this machine reaches the same directory.
692
+ */
693
+ export async function selectMachineStateRoot(input) {
694
+ const uid = input.uid ?? process.getuid?.();
695
+ if (uid === undefined)
696
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'this platform reports no user id, so no private state root can be chosen', input.home ?? '');
697
+ const selected = await selectStateBase({ platform: input.platform, home: input.home, xdgStateHome: input.xdgStateHome }, uid);
698
+ if (selected.kind !== 'complete')
699
+ return selected;
700
+ const unusable = await safeDescendant(selected.value.base, selected.value.root, uid);
701
+ if (unusable)
702
+ return unusableStateRoot(selected.value.root, unusable);
703
+ return { kind: 'complete', value: selected.value.root };
704
+ }
705
+ export async function selectBootstrapStateRoot(input) {
706
+ let stableInput;
707
+ try {
708
+ stableInput = { platform: input.platform, home: input.home, xdgStateHome: input.xdgStateHome,
709
+ checkoutRoot: input.checkoutRoot, target: input.target, uid: input.uid };
710
+ }
711
+ catch (error) {
712
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `bootstrap state root input could not be read safely: ${errorCause(error)}`, 'bootstrap state root input');
713
+ }
714
+ const uid = stableInput.uid ?? process.getuid?.();
715
+ if (uid === undefined || typeof stableInput.checkoutRoot !== 'string' || !isAbsolute(stableInput.checkoutRoot)
716
+ || resolve(stableInput.checkoutRoot) !== stableInput.checkoutRoot || stableInput.checkoutRoot.includes('\0')
717
+ || !surfaceTargets.has(stableInput.target)) {
718
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'bootstrap state root input is malformed', 'bootstrap state root input');
719
+ }
720
+ const selectedBase = await selectStateBase(stableInput, uid);
721
+ if (selectedBase.kind === 'refusal')
722
+ return selectedBase;
723
+ const { base, root } = selectedBase.value;
724
+ const unusable = await safeDescendant(base, root, uid);
725
+ if (unusable)
726
+ return unusableStateRoot(root, unusable);
727
+ if (confinedTo(stableInput.checkoutRoot, root)) {
728
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `the state root ${root} lies inside the checkout ${stableInput.checkoutRoot}`, root);
729
+ }
730
+ try {
731
+ const canonicalCheckout = await lstat(stableInput.checkoutRoot);
732
+ if (!canonicalCheckout.isDirectory() || canonicalCheckout.isSymbolicLink()
733
+ || await realpath(stableInput.checkoutRoot) !== stableInput.checkoutRoot)
734
+ throw new RefusalError('unsafe checkout');
735
+ const id = deriveCheckoutId({ checkoutPath: stableInput.checkoutRoot });
736
+ return { kind: 'complete', value: bootstrapNamespace(root, stableInput.checkoutRoot, id, stableInput.target) };
737
+ }
738
+ catch (error) {
739
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `checkout identity cannot scope the bootstrap state root safely: ${errorCause(error)}`, stableInput.checkoutRoot);
740
+ }
741
+ }
742
+ export async function selectStateRoot(input) {
743
+ let stableInput;
744
+ try {
745
+ const checkoutRoots = input.checkoutRoots;
746
+ if (checkoutRoots !== undefined && (!Array.isArray(checkoutRoots) || isProxy(checkoutRoots))) {
747
+ throw new RefusalError('checkout roots are malformed');
748
+ }
749
+ stableInput = {
750
+ platform: input.platform,
751
+ home: input.home,
752
+ xdgStateHome: input.xdgStateHome,
753
+ checkoutRoot: input.checkoutRoot,
754
+ checkoutRoots: checkoutRoots === undefined ? undefined : [...checkoutRoots],
755
+ cockpitId: input.cockpitId,
756
+ uid: input.uid,
757
+ };
758
+ }
759
+ catch (error) {
760
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `state root input could not be read safely: ${errorCause(error)}`, 'state root input');
761
+ }
762
+ const uid = stableInput.uid ?? process.getuid?.();
763
+ if (uid === undefined) {
764
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'this platform reports no user id, so no private state root can be chosen', stableInput.home ?? '');
765
+ }
766
+ const checkoutRoots = [
767
+ ...(stableInput.checkoutRoot === undefined ? [] : [stableInput.checkoutRoot]),
768
+ ...(stableInput.checkoutRoots ?? []),
769
+ ];
770
+ if (checkoutRoots.some((checkoutRoot) => typeof checkoutRoot !== 'string'
771
+ || !isAbsolute(checkoutRoot) || resolve(checkoutRoot) !== checkoutRoot || checkoutRoot.includes('\0'))) {
772
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, 'state root input contains a non-canonical checkout root', 'state root input');
773
+ }
774
+ const selectedBase = await selectStateBase(stableInput, uid);
775
+ if (selectedBase.kind === 'refusal')
776
+ return selectedBase;
777
+ const { base, root } = selectedBase.value;
778
+ const unusable = await safeDescendant(base, root, uid);
779
+ if (unusable)
780
+ return unusableStateRoot(root, unusable);
781
+ const containing = checkoutRoots.find((checkoutRoot) => confinedTo(checkoutRoot, root));
782
+ if (containing !== undefined) {
783
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `the state root ${root} lies inside the checkout ${containing}`, root);
784
+ }
785
+ try {
786
+ return { kind: 'complete', value: namespace(root, stableInput.cockpitId) };
787
+ }
788
+ catch (error) {
789
+ return refusal(ENVIRONMENT_CODES.STATE_ROOT_UNAVAILABLE, `cockpit identity cannot scope the state root safely: ${errorCause(error)}`, root);
790
+ }
791
+ }
792
+ function malformed(address, error) {
793
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state record is malformed or does not match its address${error === undefined ? '' : `: ${errorCause(error)}`}`, address.path);
794
+ }
795
+ function expectedAddressPath(address) {
796
+ if (address.kind === 'bootstrap') {
797
+ if (!isAbsolute(address.stateRoot) || !isAbsolute(address.checkoutRoot)
798
+ || resolve(address.checkoutRoot) !== address.checkoutRoot || !checkoutIdentity.test(address.checkoutId)
799
+ || !surfaceTargets.has(address.target))
800
+ return undefined;
801
+ return join(address.stateRoot, 'checkouts', identitySegment(address.checkoutId), 'records', 'bootstrap', `${address.target}.json`);
802
+ }
803
+ if (!isAbsolute(address.stateRoot) || !cockpitIdentity.test(address.cockpitId)
804
+ || address.cockpitRoot !== join(address.stateRoot, 'cockpits', identitySegment(address.cockpitId)))
805
+ return undefined;
806
+ const expectedSubject = address.kind === 'cache' ? checkoutIdentity : stackIdentity;
807
+ if (!expectedSubject.test(address.subjectId))
808
+ return undefined;
809
+ if (address.kind === 'lifecycle'
810
+ || address.kind === 'lifecycle-history'
811
+ || address.kind === 'coordination'
812
+ || address.kind === 'data-outcome') {
813
+ return join(address.cockpitRoot, 'records', address.kind, `${identitySegment(address.subjectId)}.json`);
814
+ }
815
+ if (address.kind === 'cache' && address.qualifier && digest.test(address.qualifier)) {
816
+ return join(address.cockpitRoot, 'cache', identitySegment(address.subjectId), `${address.qualifier}.json`);
817
+ }
818
+ return undefined;
819
+ }
820
+ async function addressIsSafe(address) {
821
+ const expected = expectedAddressPath(address);
822
+ if (!expected)
823
+ return false;
824
+ const scopedRoot = address.kind === 'bootstrap'
825
+ ? join(address.stateRoot, 'checkouts', identitySegment(address.checkoutId))
826
+ : address.cockpitRoot;
827
+ if (resolve(expected) !== resolve(address.path) || !confinedTo(scopedRoot, address.path))
828
+ return false;
829
+ return ownedPathIsSafe(address.stateRoot, address.path, 'file');
830
+ }
831
+ async function ownedPathIsSafe(root, path, kind) {
832
+ if (!isAbsolute(root) || !confinedTo(root, path))
833
+ return false;
834
+ let cursor = root;
835
+ const paths = [cursor];
836
+ for (const segment of relative(root, path).split(sep).filter(Boolean)) {
837
+ cursor = join(cursor, segment);
838
+ paths.push(cursor);
839
+ }
840
+ for (const candidate of paths) {
841
+ try {
842
+ const status = await lstat(candidate);
843
+ const isFile = candidate === path && kind === 'file';
844
+ if (!privatelyOwned(status) || status.isSymbolicLink() || (isFile ? !status.isFile() : !status.isDirectory()))
845
+ return false;
846
+ }
847
+ catch (error) {
848
+ if (hasErrorCode(error, 'ENOENT'))
849
+ return true;
850
+ return false;
851
+ }
852
+ }
853
+ return true;
854
+ }
855
+ async function readRegularFileSnapshotNoFollow(path, revalidate) {
856
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
857
+ try {
858
+ const opened = await handle.stat();
859
+ if (!privatelyOwned(opened) || !opened.isFile() || opened.size > SERIALIZED_RECORD_BYTE_LIMIT || !(await revalidate()))
860
+ throw new RefusalError('unsafe file path');
861
+ const current = await lstat(path);
862
+ if (!privatelyOwned(current) || current.isSymbolicLink() || !current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino) {
863
+ throw new RefusalError('file path changed while opening');
864
+ }
865
+ const bounded = Buffer.allocUnsafe(SERIALIZED_RECORD_BYTE_LIMIT + 1);
866
+ let byteLength = 0;
867
+ while (byteLength < bounded.length) {
868
+ const { bytesRead } = await handle.read(bounded, byteLength, bounded.length - byteLength, byteLength);
869
+ if (bytesRead === 0)
870
+ break;
871
+ byteLength += bytesRead;
872
+ }
873
+ if (byteLength > SERIALIZED_RECORD_BYTE_LIMIT)
874
+ throw new RefusalError('file exceeds the byte limit');
875
+ if (!(await revalidate()))
876
+ throw new RefusalError('unsafe file path after read');
877
+ const openedAfter = await handle.stat();
878
+ const after = await lstat(path);
879
+ if (openedAfter.size > SERIALIZED_RECORD_BYTE_LIMIT
880
+ || after.size > SERIALIZED_RECORD_BYTE_LIMIT
881
+ || after.isSymbolicLink()
882
+ || !privatelyOwned(after)
883
+ || !after.isFile()
884
+ || after.dev !== opened.dev
885
+ || after.ino !== opened.ino) {
886
+ throw new RefusalError('file path changed while reading');
887
+ }
888
+ return { bytes: bounded.subarray(0, byteLength), dev: opened.dev, ino: opened.ino };
889
+ }
890
+ finally {
891
+ await handle.close();
892
+ }
893
+ }
894
+ async function readRegularFileNoFollow(path, revalidate) {
895
+ return (await readRegularFileSnapshotNoFollow(path, revalidate)).bytes;
896
+ }
897
+ export async function readState(address) {
898
+ let stableAddress;
899
+ try {
900
+ if (address.kind === 'bootstrap') {
901
+ if (Reflect.ownKeys(address).sort().join(',') !== 'checkoutId,checkoutRoot,kind,path,stateRoot,target') {
902
+ throw new RefusalError('bootstrap address shape is not closed');
903
+ }
904
+ stableAddress = {
905
+ path: address.path, stateRoot: address.stateRoot, checkoutRoot: address.checkoutRoot,
906
+ kind: 'bootstrap', checkoutId: address.checkoutId, target: address.target,
907
+ };
908
+ }
909
+ else {
910
+ const qualifier = address.qualifier;
911
+ stableAddress = {
912
+ path: address.path,
913
+ stateRoot: address.stateRoot,
914
+ cockpitRoot: address.cockpitRoot,
915
+ kind: address.kind,
916
+ cockpitId: address.cockpitId,
917
+ subjectId: address.subjectId,
918
+ ...(qualifier ? { qualifier } : {}),
919
+ };
920
+ }
921
+ }
922
+ catch (error) {
923
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state record address could not be read safely: ${errorCause(error)}`, 'state record address');
924
+ }
925
+ if (!(await addressIsSafe(stableAddress)))
926
+ return malformed(stableAddress);
927
+ let bytes;
928
+ try {
929
+ bytes = await readRegularFileNoFollow(stableAddress.path, () => addressIsSafe(stableAddress));
930
+ }
931
+ catch (error) {
932
+ if (hasErrorCode(error, 'ENOENT'))
933
+ return { kind: 'missing', path: stableAddress.path };
934
+ return malformed(stableAddress, error);
935
+ }
936
+ return decodeStateRecord(stableAddress, bytes);
937
+ }
938
+ /** A record's bytes as read from an address that has already been checked safe, validated against that address. */
939
+ function decodeStateRecord(stableAddress, bytes) {
940
+ let parsed;
941
+ try {
942
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
943
+ parsed = JSON.parse(source);
944
+ }
945
+ catch (error) {
946
+ return malformed(stableAddress, error);
947
+ }
948
+ if (typeof parsed === 'object'
949
+ && parsed !== null
950
+ && 'format' in parsed
951
+ && typeof parsed.format === 'string'
952
+ && /^cockpit-state\/v[^/]+$/.test(parsed.format)
953
+ && parsed.format !== STATE_FORMAT) {
954
+ return refusal(ENVIRONMENT_CODES.STATE_FORMAT_UNSUPPORTED, 'state record format is not supported', stableAddress.path);
955
+ }
956
+ if (stableAddress.kind === 'bootstrap') {
957
+ const validated = BootstrapStateRecordSchema.safeParse(parsed);
958
+ if (!validated.success || validated.data.checkoutId !== stableAddress.checkoutId
959
+ || validated.data.target !== stableAddress.target || validated.data.value.checkoutRoot !== stableAddress.checkoutRoot
960
+ || validated.data.value.target !== stableAddress.target)
961
+ return malformed(stableAddress);
962
+ return { kind: 'complete', value: validated.data };
963
+ }
964
+ const validated = StateRecordSchema.safeParse(parsed);
965
+ if (!validated.success
966
+ || validated.data.kind !== stableAddress.kind
967
+ || validated.data.cockpitId !== stableAddress.cockpitId
968
+ || validated.data.subjectId !== stableAddress.subjectId) {
969
+ return malformed(stableAddress);
970
+ }
971
+ return { kind: 'complete', value: validated.data };
972
+ }
973
+ async function ensureDirectory(path, stateRoot) {
974
+ if (!(await ownedPathIsSafe(stateRoot, path, 'directory')))
975
+ throw new RefusalError('unsafe state directory path');
976
+ const absent = [];
977
+ let cursor = path;
978
+ while (true) {
979
+ try {
980
+ const status = await lstat(cursor);
981
+ if (!status.isDirectory() || status.isSymbolicLink() || !privatelyOwned(status) || (status.mode & 0o200) === 0)
982
+ throw new RefusalError('unsafe directory component');
983
+ break;
984
+ }
985
+ catch (error) {
986
+ if (!hasErrorCode(error, 'ENOENT'))
987
+ throw error;
988
+ absent.push(cursor);
989
+ const parent = dirname(cursor);
990
+ if (parent === cursor)
991
+ throw new RefusalError('no usable state ancestor');
992
+ cursor = parent;
993
+ }
994
+ }
995
+ for (const directory of absent.reverse()) {
996
+ try {
997
+ await mkdir(directory, { mode: 0o700 });
998
+ }
999
+ catch (error) {
1000
+ if (!hasErrorCode(error, 'EEXIST'))
1001
+ throw error;
1002
+ }
1003
+ const status = await lstat(directory);
1004
+ if (!status.isDirectory() || status.isSymbolicLink() || !privatelyOwned(status) || (status.mode & 0o200) === 0)
1005
+ throw new RefusalError('unsafe directory component');
1006
+ }
1007
+ if (!(await ownedPathIsSafe(stateRoot, path, 'directory')))
1008
+ throw new RefusalError('state directory path changed during creation');
1009
+ }
1010
+ function validateRecordForAddress(address, value, current) {
1011
+ if (address.kind === 'bootstrap') {
1012
+ const validated = BootstrapStateRecordSchema.safeParse(value);
1013
+ if (!validated.success)
1014
+ return undefined;
1015
+ const record = validated.data;
1016
+ const expectedRevision = current === null ? 0 : current.revision + 1;
1017
+ const currentBootstrap = current?.kind === 'bootstrap' ? current : null;
1018
+ const immutableEvidenceMatches = currentBootstrap === null
1019
+ || record.value.commandSha256 === currentBootstrap.value.commandSha256
1020
+ && record.value.previewSha256 === currentBootstrap.value.previewSha256
1021
+ && JSON.stringify(record.value.sources) === JSON.stringify(currentBootstrap.value.sources);
1022
+ const stageDelta = currentBootstrap === null ? 0
1023
+ : bootstrapStages.indexOf(record.value.stage) - bootstrapStages.indexOf(currentBootstrap.value.stage);
1024
+ const stageAdvances = currentBootstrap === null || stageDelta === 0 || stageDelta === 1;
1025
+ const publishedEvidenceMatches = currentBootstrap === null || currentBootstrap.value.published.length === 0
1026
+ || JSON.stringify(record.value.published) === JSON.stringify(currentBootstrap.value.published);
1027
+ const draftEvidenceMatches = currentBootstrap === null || currentBootstrap.value.destination === null
1028
+ || record.value.destination === currentBootstrap.value.destination
1029
+ && record.value.artifactBytes === currentBootstrap.value.artifactBytes
1030
+ && record.value.artifactSha256 === currentBootstrap.value.artifactSha256;
1031
+ const currentGreenfield = currentBootstrap?.value.greenfield;
1032
+ const nextGreenfield = record.value.greenfield;
1033
+ const greenfieldEvidenceMatches = currentGreenfield === undefined
1034
+ ? nextGreenfield === undefined || nextGreenfield.answers.length === 0
1035
+ && (nextGreenfield.stage === 'question-pending' || nextGreenfield.stage === 'resolved')
1036
+ : nextGreenfield !== undefined
1037
+ && nextGreenfield.mode === currentGreenfield.mode
1038
+ && nextGreenfield.classification === currentGreenfield.classification
1039
+ && nextGreenfield.sourceObservationSha256 === currentGreenfield.sourceObservationSha256
1040
+ && nextGreenfield.notice === currentGreenfield.notice
1041
+ && JSON.stringify(nextGreenfield.questions.slice(0, currentGreenfield.questions.length)) === JSON.stringify(currentGreenfield.questions)
1042
+ && nextGreenfield.questions.length - currentGreenfield.questions.length >= 0
1043
+ && nextGreenfield.questions.length - currentGreenfield.questions.length <= 1
1044
+ && JSON.stringify(nextGreenfield.answers.slice(0, currentGreenfield.answers.length)) === JSON.stringify(currentGreenfield.answers)
1045
+ && nextGreenfield.answers.length - currentGreenfield.answers.length >= 0
1046
+ && nextGreenfield.answers.length - currentGreenfield.answers.length <= 1
1047
+ && (() => {
1048
+ const stages = ['question-pending', 'answers-recorded', 'resolved'];
1049
+ const delta = stages.indexOf(nextGreenfield.stage) - stages.indexOf(currentGreenfield.stage);
1050
+ return delta === 0 || delta === 1;
1051
+ })();
1052
+ const authorizedReset = currentBootstrap !== null
1053
+ && (issuedBootstrapAnswerResets.has(value) || issuedBootstrapObservationRefreshes.has(value));
1054
+ const authorizedPublicationRefresh = currentBootstrap !== null && issuedBootstrapPublicationRefreshes.has(value);
1055
+ // A restart may only return to a fresh preview that carries no publication, draft or answers.
1056
+ const authorizedRestart = currentBootstrap !== null && issuedBootstrapRestarts.has(value)
1057
+ && record.value.stage === 'surface-previewed' && record.value.published.length === 0 && record.value.destination === null
1058
+ && record.value.artifactBytes === null && record.value.artifactSha256 === null && record.value.greenfield === undefined;
1059
+ return record.checkoutId === address.checkoutId && record.target === address.target
1060
+ && record.value.checkoutRoot === address.checkoutRoot && record.value.target === address.target
1061
+ && record.revision === expectedRevision && (authorizedRestart || stageAdvances
1062
+ && (immutableEvidenceMatches && publishedEvidenceMatches || authorizedPublicationRefresh)
1063
+ && draftEvidenceMatches && (greenfieldEvidenceMatches || authorizedReset)) ? record : undefined;
1064
+ }
1065
+ const validated = StateRecordSchema.safeParse(value);
1066
+ if (!validated.success)
1067
+ return undefined;
1068
+ const record = validated.data;
1069
+ const expectedRevision = current === null ? 0 : current.revision + 1;
1070
+ if (record.kind !== address.kind
1071
+ || record.cockpitId !== address.cockpitId
1072
+ || record.subjectId !== address.subjectId
1073
+ || record.revision !== expectedRevision)
1074
+ return undefined;
1075
+ return record;
1076
+ }
1077
+ function answerResetRefusal(code, message, target) {
1078
+ return { kind: 'refusal', code, message, target };
1079
+ }
1080
+ function bootstrapAnswerResetPreview(record, field, target) {
1081
+ if (record === null || record.value.stage !== 'surface-published' || !record.value.greenfield) {
1082
+ return answerResetRefusal('BOOTSTRAP_ANSWER_RESET_UNAVAILABLE', 'recorded answers can be reset only after surface publication and before configuration publication', target);
1083
+ }
1084
+ if (typeof field !== 'string' || field.length < 1 || field.length > 256) {
1085
+ return answerResetRefusal('BOOTSTRAP_ANSWER_RESET_FIELD_INVALID', 'reset answer field is malformed', field);
1086
+ }
1087
+ const matches = record.value.greenfield.questions
1088
+ .map((question, index) => ({ question, index }))
1089
+ .filter(({ question, index }) => question.field === field && index < record.value.greenfield.answers.length);
1090
+ if (matches.length !== 1) {
1091
+ return answerResetRefusal('BOOTSTRAP_ANSWER_RESET_FIELD_INVALID', 'reset answer field must name exactly one recorded answer', field);
1092
+ }
1093
+ const index = matches[0].index;
1094
+ const invalidatedFields = record.value.greenfield.questions.slice(index).map(question => question.field);
1095
+ const previewSha256 = createHash('sha256').update(JSON.stringify({
1096
+ checkoutId: record.checkoutId, target: record.target, revision: record.revision,
1097
+ field, questionId: matches[0].question.id, invalidatedFields,
1098
+ })).digest('hex');
1099
+ return { kind: 'pending', preview: {
1100
+ revision: record.revision, field, invalidatedFields, previewSha256,
1101
+ } };
1102
+ }
1103
+ export async function resetBootstrapAnswer(namespace, address, input) {
1104
+ let stableInput;
1105
+ try {
1106
+ stableInput = { ownerId: input.ownerId, field: input.field, updatedAt: input.updatedAt,
1107
+ ...(input.acceptedPreviewSha256 === undefined ? {} : { acceptedPreviewSha256: input.acceptedPreviewSha256 }) };
1108
+ }
1109
+ catch (error) {
1110
+ return answerResetRefusal('BOOTSTRAP_ANSWER_RESET_FIELD_INVALID', `recorded answer reset input could not be read safely: ${errorCause(error)}`, 'recorded answer reset input');
1111
+ }
1112
+ let acceptedPreview;
1113
+ const outcome = await transactState(namespace, address, { ownerId: stableInput.ownerId }, current => {
1114
+ const preview = bootstrapAnswerResetPreview(current, stableInput.field, address.path);
1115
+ if (preview.kind !== 'pending')
1116
+ return abortStateTransaction(stableInput.acceptedPreviewSha256 === undefined ? preview
1117
+ : answerResetRefusal('BOOTSTRAP_ANSWER_RESET_STALE', 'recorded answer reset preview no longer matches the exact state revision', address.path));
1118
+ if (stableInput.acceptedPreviewSha256 === undefined)
1119
+ return abortStateTransaction(preview);
1120
+ if (stableInput.acceptedPreviewSha256 !== preview.preview.previewSha256) {
1121
+ return abortStateTransaction(answerResetRefusal('BOOTSTRAP_ANSWER_RESET_STALE', 'recorded answer reset preview no longer matches the exact state revision', address.path));
1122
+ }
1123
+ acceptedPreview = preview.preview;
1124
+ const greenfield = current.value.greenfield;
1125
+ const index = greenfield.questions.findIndex((question, questionIndex) => question.field === stableInput.field && questionIndex < greenfield.answers.length);
1126
+ const record = {
1127
+ ...current, revision: current.revision + 1, updatedAt: stableInput.updatedAt,
1128
+ value: { ...current.value, greenfield: { ...greenfield, stage: 'question-pending',
1129
+ questions: greenfield.questions.slice(0, index + 1), answers: greenfield.answers.slice(0, index) } },
1130
+ };
1131
+ issuedBootstrapAnswerResets.add(record);
1132
+ return record;
1133
+ });
1134
+ if ('aborted' in outcome)
1135
+ return outcome.value;
1136
+ if (outcome.kind === 'refusal')
1137
+ return answerResetRefusal(outcome.code, outcome.message, outcome.target);
1138
+ if (outcome.kind !== 'complete')
1139
+ return answerResetRefusal('BOOTSTRAP_ANSWER_RESET_INDETERMINATE', 'recorded answer reset durability could not be confirmed', address.path);
1140
+ if (!acceptedPreview)
1141
+ return answerResetRefusal('BOOTSTRAP_ANSWER_RESET_STALE', 'recorded answer reset did not retain its authenticated preview', address.path);
1142
+ return { kind: 'complete', preview: acceptedPreview, record: outcome.value };
1143
+ }
1144
+ function observationRefreshRefusal(reason, message) {
1145
+ return { kind: 'refusal', reason, message };
1146
+ }
1147
+ export async function refreshBootstrapObservation(namespace, address, input) {
1148
+ let stableInput;
1149
+ try {
1150
+ const parsed = GreenfieldStateSchema.safeParse(input.greenfield);
1151
+ if (!parsed.success || !Number.isInteger(input.expectedRevision) || input.expectedRevision < 0
1152
+ || !digest.test(input.expectedSourceObservationSha256)
1153
+ || !isCanonicalTimestamp(input.updatedAt) || parsed.data.answers.length !== 0
1154
+ || (parsed.data.mode ?? 'estate') !== 'estate'
1155
+ || parsed.data.sourceObservationSha256 === input.expectedSourceObservationSha256) {
1156
+ return observationRefreshRefusal('invalid', 'fresh estate observation replacement is malformed');
1157
+ }
1158
+ stableInput = { ownerId: input.ownerId, expectedRevision: input.expectedRevision,
1159
+ expectedSourceObservationSha256: input.expectedSourceObservationSha256,
1160
+ greenfield: parsed.data, updatedAt: input.updatedAt };
1161
+ }
1162
+ catch (error) {
1163
+ return observationRefreshRefusal('invalid', `fresh estate observation replacement could not be read safely: ${errorCause(error)}`);
1164
+ }
1165
+ const outcome = await transactState(namespace, address, { ownerId: stableInput.ownerId }, current => {
1166
+ const greenfield = current?.value.greenfield;
1167
+ if (current === null || current.revision !== stableInput.expectedRevision
1168
+ || current.value.stage !== 'surface-published' || !greenfield
1169
+ || (greenfield.mode ?? 'estate') !== 'estate'
1170
+ || greenfield.sourceObservationSha256 !== stableInput.expectedSourceObservationSha256) {
1171
+ return abortStateTransaction(observationRefreshRefusal('stale', 'bootstrap estate observation no longer matches the exact persisted interview'));
1172
+ }
1173
+ const record = {
1174
+ ...current, revision: current.revision + 1, updatedAt: stableInput.updatedAt,
1175
+ value: { ...current.value, greenfield: stableInput.greenfield },
1176
+ };
1177
+ issuedBootstrapObservationRefreshes.add(record);
1178
+ return record;
1179
+ });
1180
+ if ('aborted' in outcome)
1181
+ return outcome.value;
1182
+ return outcome;
1183
+ }
1184
+ function publicationRefreshRefusal(reason, message) {
1185
+ return { kind: 'refusal', reason, message };
1186
+ }
1187
+ export async function refreshBootstrapPublication(namespace, address, input) {
1188
+ let stableInput;
1189
+ try {
1190
+ const expectedSources = BootstrapDigestRowSchema.array().min(1).max(16).safeParse(input.expectedSources);
1191
+ const expectedPublished = BootstrapDigestRowSchema.array().max(BOOTSTRAP_PUBLISHED_MAX).safeParse(input.expectedPublished);
1192
+ const sources = BootstrapDigestRowSchema.array().min(1).max(16).safeParse(input.sources);
1193
+ const published = BootstrapDigestRowSchema.array().max(BOOTSTRAP_PUBLISHED_MAX).safeParse(input.published);
1194
+ if (!expectedSources.success || !expectedPublished.success || !sources.success || !published.success
1195
+ || !Number.isInteger(input.expectedRevision) || input.expectedRevision < 0
1196
+ || !digest.test(input.expectedCommandSha256) || !digest.test(input.expectedPreviewSha256) || !digest.test(input.previewSha256)
1197
+ || !isCanonicalTimestamp(input.updatedAt)
1198
+ || JSON.stringify(expectedPublished.data.map(row => row.path)) !== JSON.stringify(published.data.map(row => row.path))
1199
+ || createHash('sha256').update(JSON.stringify({ target: address.target, sources: expectedSources.data,
1200
+ files: expectedPublished.data })).digest('hex') !== input.expectedPreviewSha256
1201
+ || createHash('sha256').update(JSON.stringify({ target: address.target, sources: sources.data, files: published.data })).digest('hex')
1202
+ !== input.previewSha256
1203
+ || input.expectedPreviewSha256 === input.previewSha256
1204
+ && JSON.stringify(expectedSources.data) === JSON.stringify(sources.data)
1205
+ && JSON.stringify(expectedPublished.data) === JSON.stringify(published.data)) {
1206
+ return publicationRefreshRefusal('invalid', 'bootstrap publication refresh evidence is malformed');
1207
+ }
1208
+ stableInput = { ownerId: input.ownerId, expectedRevision: input.expectedRevision,
1209
+ expectedSources: expectedSources.data, expectedPreviewSha256: input.expectedPreviewSha256,
1210
+ expectedCommandSha256: input.expectedCommandSha256,
1211
+ expectedPublished: expectedPublished.data, sources: sources.data,
1212
+ previewSha256: input.previewSha256, published: published.data, updatedAt: input.updatedAt };
1213
+ }
1214
+ catch (error) {
1215
+ return publicationRefreshRefusal('invalid', `bootstrap publication refresh evidence could not be read safely: ${errorCause(error)}`);
1216
+ }
1217
+ const outcome = await transactState(namespace, address, { ownerId: stableInput.ownerId }, current => {
1218
+ if (current === null || current.revision !== stableInput.expectedRevision
1219
+ || current.value.stage !== 'surface-published'
1220
+ || current.value.commandSha256 !== stableInput.expectedCommandSha256
1221
+ || current.value.previewSha256 !== stableInput.expectedPreviewSha256
1222
+ || JSON.stringify(current.value.sources) !== JSON.stringify(stableInput.expectedSources)
1223
+ || JSON.stringify(current.value.published) !== JSON.stringify(stableInput.expectedPublished)) {
1224
+ return abortStateTransaction(publicationRefreshRefusal('stale', 'bootstrap publication no longer matches the exact accepted preview'));
1225
+ }
1226
+ const record = { ...current, revision: current.revision + 1, updatedAt: stableInput.updatedAt,
1227
+ value: { ...current.value, sources: stableInput.sources, previewSha256: stableInput.previewSha256,
1228
+ published: stableInput.published } };
1229
+ issuedBootstrapPublicationRefreshes.add(record);
1230
+ return record;
1231
+ });
1232
+ if ('aborted' in outcome)
1233
+ return outcome.value;
1234
+ return outcome;
1235
+ }
1236
+ /**
1237
+ * Replaces bootstrap state whose publication is gone from the checkout with a fresh preview record.
1238
+ * The caller proves the absence; the expected revision keeps a concurrent init from being discarded.
1239
+ */
1240
+ export async function restartBootstrapState(namespace, address, input) {
1241
+ const outcome = await transactState(namespace, address, { ownerId: input.ownerId }, current => {
1242
+ if (current === null || current.revision !== input.expectedRevision)
1243
+ return abortStateTransaction({ kind: 'stale' });
1244
+ const record = { ...current, revision: current.revision + 1, updatedAt: input.updatedAt,
1245
+ value: { stage: 'surface-previewed', checkoutRoot: current.value.checkoutRoot, target: current.value.target,
1246
+ sources: [...input.value.sources], commandSha256: input.value.commandSha256, previewSha256: input.value.previewSha256,
1247
+ published: [], destination: null, artifactBytes: null, artifactSha256: null } };
1248
+ issuedBootstrapRestarts.add(record);
1249
+ return record;
1250
+ });
1251
+ return 'aborted' in outcome ? outcome.value : outcome;
1252
+ }
1253
+ async function inspectLock(lockPath, stateRoot, observeHolder) {
1254
+ const ownerPath = join(lockPath, 'owner.json');
1255
+ try {
1256
+ const bytes = await readRegularFileNoFollow(ownerPath, async () => {
1257
+ const lock = await lstat(lockPath);
1258
+ return lock.isDirectory() && !lock.isSymbolicLink() && privatelyOwned(lock) && await ownedPathIsSafe(stateRoot, ownerPath, 'file');
1259
+ });
1260
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1261
+ const parsed = StateLockSchema.safeParse(JSON.parse(source));
1262
+ if (!parsed.success)
1263
+ throw new RefusalError('invalid lock metadata');
1264
+ let holderState = 'unknown';
1265
+ let observationFailure = '';
1266
+ if (observeHolder) {
1267
+ try {
1268
+ holderState = await observeHolder({ ownerId: parsed.data.ownerId, pid: parsed.data.pid });
1269
+ }
1270
+ catch (error) {
1271
+ observationFailure = `; holder observation failed: ${errorCause(error)}`;
1272
+ }
1273
+ }
1274
+ return {
1275
+ holderState,
1276
+ description: `state is locked by ${parsed.data.ownerId} (${holderState}${observationFailure})`,
1277
+ recovery: holderState === 'dead'
1278
+ ? 'request explicit state-lock reclamation using the recorded holder evidence'
1279
+ : 'inspect the named holder and retry after it releases the state lock',
1280
+ };
1281
+ }
1282
+ catch (error) {
1283
+ return {
1284
+ holderState: 'invalid',
1285
+ description: `state lock metadata is missing or malformed: ${errorCause(error)}`,
1286
+ recovery: 'inspect the lock directory and establish positive holder evidence before explicit reclamation',
1287
+ };
1288
+ }
1289
+ }
1290
+ export async function inspectStateLock(namespace, observeHolder) {
1291
+ const bootstrap = authenticateBootstrapStateNamespace(namespace);
1292
+ const stableNamespace = bootstrap
1293
+ ? snapshotBootstrapStateNamespace(bootstrap)
1294
+ : snapshotStateNamespace(namespace);
1295
+ if (!stableNamespace) {
1296
+ return {
1297
+ holderState: 'invalid',
1298
+ description: 'state lock namespace is malformed',
1299
+ recovery: 'inspect the state namespace before relying on lock ownership',
1300
+ };
1301
+ }
1302
+ return inspectLock(stableNamespace.paths.stateLock, stableNamespace.root, observeHolder);
1303
+ }
1304
+ async function acquireLock(namespace, options, ownerPattern = holderIdentity) {
1305
+ if (!ownerPattern.test(options.ownerId)) {
1306
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, 'state lock owner is invalid', namespace.paths.stateLock);
1307
+ }
1308
+ const locksRoot = dirname(namespace.paths.stateLock);
1309
+ await ensureDirectory(locksRoot, namespace.root);
1310
+ const existingQuarantine = await releaseQuarantine(locksRoot);
1311
+ if (existingQuarantine) {
1312
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, 'state has a quarantined lock awaiting explicit recovery', existingQuarantine, await quarantineRecovery(existingQuarantine));
1313
+ }
1314
+ let createdLock = false;
1315
+ let createdNode;
1316
+ try {
1317
+ await mkdir(namespace.paths.stateLock, { mode: 0o700 });
1318
+ createdLock = true;
1319
+ const created = await lstat(namespace.paths.stateLock);
1320
+ createdNode = created;
1321
+ let concurrentQuarantine;
1322
+ try {
1323
+ concurrentQuarantine = await releaseQuarantine(locksRoot);
1324
+ }
1325
+ catch (error) {
1326
+ createdLock = false;
1327
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, `state locks root changed while acquiring the state lock: ${errorCause(error)}`, namespace.paths.stateLock, 'inspect the state locks root and establish positive holder evidence before explicit recovery');
1328
+ }
1329
+ if (concurrentQuarantine) {
1330
+ const current = await lstat(namespace.paths.stateLock);
1331
+ createdLock = false;
1332
+ let leftover = '';
1333
+ if (current.isDirectory() && !current.isSymbolicLink() && sameNode(current, created)) {
1334
+ try {
1335
+ await rmdir(namespace.paths.stateLock);
1336
+ }
1337
+ catch (error) {
1338
+ // A lock directory this acquisition created but could not remove blocks every
1339
+ // later acquisition, so the operator must be told it exists.
1340
+ leftover = `; the lock directory this acquisition created at ${namespace.paths.stateLock} could not be removed: ${errorCause(error)}`;
1341
+ }
1342
+ }
1343
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, `state lock release raced with acquisition and remains quarantined${leftover}`, concurrentQuarantine, await quarantineRecovery(concurrentQuarantine));
1344
+ }
1345
+ const owner = {
1346
+ format: STATE_FORMAT,
1347
+ kind: 'state-lock',
1348
+ ownerId: options.ownerId,
1349
+ pid: process.pid,
1350
+ acquiredAt: new Date().toISOString(),
1351
+ token: randomUUID(),
1352
+ };
1353
+ const ownerBytes = `${JSON.stringify(owner)}\n`;
1354
+ const handle = await open(join(namespace.paths.stateLock, 'owner.json'), 'wx', 0o600);
1355
+ try {
1356
+ await handle.writeFile(ownerBytes, 'utf8');
1357
+ await handle.sync();
1358
+ }
1359
+ finally {
1360
+ await handle.close();
1361
+ }
1362
+ return { kind: 'acquired', ownerBytes };
1363
+ }
1364
+ catch (error) {
1365
+ if (createdLock) {
1366
+ try {
1367
+ const current = await lstat(namespace.paths.stateLock);
1368
+ const entries = await readdir(namespace.paths.stateLock);
1369
+ if (createdNode
1370
+ && current.isDirectory()
1371
+ && !current.isSymbolicLink()
1372
+ && sameNode(current, createdNode)
1373
+ && entries.length === 0) {
1374
+ await rmdir(namespace.paths.stateLock);
1375
+ }
1376
+ }
1377
+ catch {
1378
+ // A partially created or replaced lock is authority evidence and must survive for recovery.
1379
+ }
1380
+ }
1381
+ if (!hasErrorCode(error, 'EEXIST'))
1382
+ throw error;
1383
+ const observed = await inspectLock(namespace.paths.stateLock, namespace.root, options.observeHolder);
1384
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, observed.description, namespace.paths.stateLock, observed.recovery);
1385
+ }
1386
+ }
1387
+ async function releaseQuarantine(locksRoot) {
1388
+ const before = await lstat(locksRoot);
1389
+ if (!before.isDirectory() || before.isSymbolicLink() || !privatelyOwned(before))
1390
+ throw new RefusalError('state locks root is unsafe');
1391
+ const names = await readdir(locksRoot);
1392
+ const after = await lstat(locksRoot);
1393
+ if (!sameNode(before, after))
1394
+ throw new RefusalError('state locks root changed during quarantine inspection');
1395
+ const name = names.filter((entry) => entry.startsWith('.state.lock.release-')).sort()[0];
1396
+ return name ? join(locksRoot, name) : undefined;
1397
+ }
1398
+ async function quarantineRecovery(path) {
1399
+ const evidencePath = path.endsWith('.owner.json') ? path : `${path}.owner.json`;
1400
+ try {
1401
+ const evidence = await lstat(evidencePath);
1402
+ if (evidence.isFile() && !evidence.isSymbolicLink()) {
1403
+ return `inspect the quarantined lock owner evidence at ${evidencePath} before explicit recovery`;
1404
+ }
1405
+ }
1406
+ catch (error) {
1407
+ if (!hasErrorCode(error, 'ENOENT'))
1408
+ throw error;
1409
+ }
1410
+ return 'inspect the quarantined lock holder and restore or reclaim it using positive owner evidence';
1411
+ }
1412
+ function sameNode(actual, expected) {
1413
+ return actual.dev === expected.dev && actual.ino === expected.ino;
1414
+ }
1415
+ class LockReleaseError extends RefusalError {
1416
+ target;
1417
+ recovery;
1418
+ constructor(message, target, recovery) {
1419
+ super(message);
1420
+ this.target = target;
1421
+ this.recovery = recovery;
1422
+ }
1423
+ }
1424
+ function samePrivateNode(actual, expected) {
1425
+ return privatelyOwned(actual) && sameNode(actual, expected);
1426
+ }
1427
+ async function releaseLock(lockPath, ownerBytes, beforeRelease, beforeLockMetadata) {
1428
+ const releaseRoot = dirname(lockPath);
1429
+ const ownerPath = join(lockPath, 'owner.json');
1430
+ const verifiedRoot = await lstat(releaseRoot);
1431
+ const verifiedLock = await lstat(lockPath);
1432
+ if (!verifiedRoot.isDirectory()
1433
+ || verifiedRoot.isSymbolicLink()
1434
+ || !privatelyOwned(verifiedRoot)
1435
+ || !verifiedLock.isDirectory()
1436
+ || verifiedLock.isSymbolicLink()
1437
+ || !privatelyOwned(verifiedLock)) {
1438
+ throw new RefusalError('state lock changed before release');
1439
+ }
1440
+ const currentOwner = await readRegularFileSnapshotNoFollow(ownerPath, async () => {
1441
+ const root = await lstat(releaseRoot);
1442
+ const lock = await lstat(lockPath);
1443
+ return samePrivateNode(root, verifiedRoot)
1444
+ && lock.isDirectory()
1445
+ && !lock.isSymbolicLink()
1446
+ && samePrivateNode(lock, verifiedLock);
1447
+ });
1448
+ if (currentOwner.bytes.toString('utf8') !== ownerBytes) {
1449
+ throw new RefusalError('state lock ownership changed before release');
1450
+ }
1451
+ const parsedOwner = StateLockSchema.parse(JSON.parse(ownerBytes));
1452
+ await beforeRelease?.();
1453
+ if (!samePrivateNode(lstatSync(releaseRoot), verifiedRoot)
1454
+ || !samePrivateNode(lstatSync(lockPath), verifiedLock)
1455
+ || !samePrivateNode(lstatSync(ownerPath), currentOwner)) {
1456
+ throw new RefusalError('state lock permissions or ownership changed before release');
1457
+ }
1458
+ const tombstone = join(releaseRoot, `.state.lock.release-${parsedOwner.token}`);
1459
+ const movedOwnerPath = join(tombstone, 'owner.json');
1460
+ let evidencePath = movedOwnerPath;
1461
+ await rename(lockPath, tombstone);
1462
+ try {
1463
+ await syncDirectory(releaseRoot);
1464
+ const movedRoot = await lstat(releaseRoot);
1465
+ const movedLock = await lstat(tombstone);
1466
+ if (!samePrivateNode(movedRoot, verifiedRoot)
1467
+ || !movedLock.isDirectory()
1468
+ || movedLock.isSymbolicLink()
1469
+ || !samePrivateNode(movedLock, verifiedLock)) {
1470
+ throw new RefusalError('moved lock directory does not match verified owner');
1471
+ }
1472
+ const movedOwner = await readRegularFileSnapshotNoFollow(movedOwnerPath, async () => {
1473
+ const root = await lstat(releaseRoot);
1474
+ const lock = await lstat(tombstone);
1475
+ return samePrivateNode(root, verifiedRoot)
1476
+ && lock.isDirectory()
1477
+ && !lock.isSymbolicLink()
1478
+ && samePrivateNode(lock, movedLock);
1479
+ });
1480
+ if (!sameNode(movedOwner, currentOwner) || movedOwner.bytes.toString('utf8') !== ownerBytes) {
1481
+ throw new RefusalError('moved lock owner does not match verified owner');
1482
+ }
1483
+ const entries = await readdir(tombstone);
1484
+ if (entries.length !== 1 || entries[0] !== 'owner.json') {
1485
+ throw new RefusalError('quarantined lock contains unexpected entries');
1486
+ }
1487
+ const deletionRoot = await lstat(releaseRoot);
1488
+ const deletionLock = await lstat(tombstone);
1489
+ const deletionOwner = await lstat(movedOwnerPath);
1490
+ if (!samePrivateNode(deletionRoot, verifiedRoot)
1491
+ || !samePrivateNode(deletionLock, movedLock)
1492
+ || !samePrivateNode(deletionOwner, movedOwner)) {
1493
+ throw new RefusalError('quarantined lock changed before deletion');
1494
+ }
1495
+ const ownerBackupPath = `${tombstone}.owner.json`;
1496
+ await link(movedOwnerPath, ownerBackupPath);
1497
+ await syncDirectory(releaseRoot);
1498
+ const linkedOwner = await lstat(movedOwnerPath);
1499
+ const linkedBackup = await lstat(ownerBackupPath);
1500
+ if (!samePrivateNode(linkedOwner, movedOwner) || !samePrivateNode(linkedBackup, movedOwner)) {
1501
+ throw new RefusalError('quarantined lock owner changed after evidence link');
1502
+ }
1503
+ // Node has no portable fd-relative conditional unlink. This checkpoint protects cooperative
1504
+ // concurrent processes and injected pre-syscall interference, not a malicious same-UID syscall race.
1505
+ await beforeLockMetadata?.('owner-unlink');
1506
+ if (!samePrivateNode(lstatSync(releaseRoot), verifiedRoot)
1507
+ || !samePrivateNode(lstatSync(tombstone), movedLock)) {
1508
+ throw new RefusalError('state lock permissions changed before owner unlink');
1509
+ }
1510
+ const ownerBeforeUnlink = await lstat(movedOwnerPath);
1511
+ const backupBeforeUnlink = await lstat(ownerBackupPath);
1512
+ if (!samePrivateNode(ownerBeforeUnlink, movedOwner) || !samePrivateNode(backupBeforeUnlink, movedOwner)) {
1513
+ throw new RefusalError('quarantined lock owner changed before unlink');
1514
+ }
1515
+ await unlink(movedOwnerPath);
1516
+ evidencePath = ownerBackupPath;
1517
+ try {
1518
+ await syncDirectory(tombstone);
1519
+ }
1520
+ catch (error) {
1521
+ await restoreOwnerEvidence(ownerBackupPath, movedOwnerPath, movedOwner, tombstone, beforeLockMetadata);
1522
+ evidencePath = movedOwnerPath;
1523
+ throw error;
1524
+ }
1525
+ try {
1526
+ const emptyRoot = await lstat(releaseRoot);
1527
+ const emptyLock = await lstat(tombstone);
1528
+ if (!samePrivateNode(emptyRoot, verifiedRoot) || !samePrivateNode(emptyLock, movedLock)) {
1529
+ throw new RefusalError('quarantined lock changed before directory removal');
1530
+ }
1531
+ await beforeLockMetadata?.('tombstone-rmdir');
1532
+ if (!samePrivateNode(lstatSync(releaseRoot), verifiedRoot)
1533
+ || !samePrivateNode(lstatSync(tombstone), movedLock)) {
1534
+ throw new RefusalError('state lock permissions changed before directory removal');
1535
+ }
1536
+ await rmdir(tombstone);
1537
+ await syncDirectory(releaseRoot);
1538
+ }
1539
+ catch (error) {
1540
+ try {
1541
+ await restoreOwnerEvidence(ownerBackupPath, movedOwnerPath, movedOwner, tombstone, beforeLockMetadata);
1542
+ evidencePath = movedOwnerPath;
1543
+ }
1544
+ catch (restoreError) {
1545
+ throw new RefusalError(`${errorCause(error)}; owner evidence remains at ${evidencePath}; restoration failed: ${errorCause(restoreError)}`);
1546
+ }
1547
+ throw error;
1548
+ }
1549
+ await beforeLockMetadata?.('backup-unlink');
1550
+ if (!samePrivateNode(lstatSync(releaseRoot), verifiedRoot))
1551
+ throw new RefusalError('state locks root permissions changed before backup cleanup');
1552
+ const finalBackup = await lstat(ownerBackupPath);
1553
+ if (!samePrivateNode(finalBackup, movedOwner))
1554
+ throw new RefusalError('owner evidence backup changed before cleanup');
1555
+ await unlink(ownerBackupPath);
1556
+ try {
1557
+ await syncDirectory(releaseRoot);
1558
+ }
1559
+ catch (error) {
1560
+ evidencePath = ownerBackupPath;
1561
+ const evidence = await open(evidencePath, 'wx', 0o600);
1562
+ try {
1563
+ await evidence.writeFile(ownerBytes, 'utf8');
1564
+ await evidence.sync();
1565
+ }
1566
+ finally {
1567
+ await evidence.close();
1568
+ }
1569
+ await syncDirectory(releaseRoot).catch(() => undefined);
1570
+ throw error;
1571
+ }
1572
+ }
1573
+ catch (error) {
1574
+ throw new LockReleaseError(`state lock release quarantined an unverified holder: ${errorCause(error)}`, tombstone, evidencePath === movedOwnerPath
1575
+ ? 'inspect the quarantined lock holder and restore or reclaim it using positive owner evidence'
1576
+ : `inspect the quarantined lock owner evidence at ${evidencePath} before explicit recovery`);
1577
+ }
1578
+ }
1579
+ export async function withStateScopedLock(namespace, scope, options, operation) {
1580
+ const authenticated = authenticateStateNamespace(namespace);
1581
+ const binding = scope !== null && typeof scope === 'object' ? issuedScopedLocks.get(scope) : undefined;
1582
+ let stableOptions;
1583
+ try {
1584
+ if (!options || typeof options !== 'object' || isProxy(options)
1585
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options)))
1586
+ throw new RefusalError('invalid options');
1587
+ const descriptors = Object.getOwnPropertyDescriptors(options);
1588
+ const allowed = new Set(['ownerId', 'observeHolder', 'contentionTimeoutMs', 'beforeRelease', 'beforeLockMetadata']);
1589
+ if (!descriptors.ownerId || !('value' in descriptors.ownerId)
1590
+ || Reflect.ownKeys(descriptors).some(key => typeof key !== 'string' || !allowed.has(key))
1591
+ || Object.values(descriptors).some(descriptor => !('value' in descriptor) || !descriptor.enumerable))
1592
+ throw new RefusalError('invalid options');
1593
+ const values = Object.fromEntries(Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]));
1594
+ if (typeof values.ownerId !== 'string'
1595
+ || values.observeHolder !== undefined && typeof values.observeHolder !== 'function'
1596
+ || values.contentionTimeoutMs !== undefined && typeof values.contentionTimeoutMs !== 'number'
1597
+ || values.beforeRelease !== undefined && typeof values.beforeRelease !== 'function'
1598
+ || values.beforeLockMetadata !== undefined && typeof values.beforeLockMetadata !== 'function')
1599
+ throw new RefusalError('invalid options');
1600
+ stableOptions = { ownerId: values.ownerId,
1601
+ ...(values.observeHolder ? { observeHolder: values.observeHolder } : {}),
1602
+ ...(values.contentionTimeoutMs !== undefined ? { contentionTimeoutMs: values.contentionTimeoutMs } : {}),
1603
+ ...(values.beforeRelease ? { beforeRelease: values.beforeRelease } : {}),
1604
+ ...(values.beforeLockMetadata ? { beforeLockMetadata: values.beforeLockMetadata } : {}) };
1605
+ }
1606
+ catch {
1607
+ // A throwing accessor or proxy trap makes the input malformed, which the caller refuses.
1608
+ stableOptions = undefined;
1609
+ }
1610
+ if (!authenticated || !binding || binding.namespace !== authenticated || !stableOptions || typeof operation !== 'function') {
1611
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'scoped state lock authority is malformed', 'scoped state lock');
1612
+ }
1613
+ const stableNamespace = { root: authenticated.root, cockpitRoot: authenticated.cockpitRoot,
1614
+ paths: { stateLock: binding.path } };
1615
+ if (!holderIdentity.test(stableOptions.ownerId)
1616
+ || stableOptions.contentionTimeoutMs !== undefined && (!Number.isSafeInteger(stableOptions.contentionTimeoutMs)
1617
+ || stableOptions.contentionTimeoutMs < 0 || stableOptions.contentionTimeoutMs > 30_000)) {
1618
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'scoped state lock options are malformed', binding.path);
1619
+ }
1620
+ let acquired;
1621
+ const deadline = Date.now() + (stableOptions.contentionTimeoutMs ?? 0);
1622
+ let backoffMs = 5;
1623
+ while (true) {
1624
+ try {
1625
+ acquired = await acquireLock(stableNamespace, stableOptions);
1626
+ }
1627
+ catch (error) {
1628
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, `scoped state lock could not be acquired safely: ${errorCause(error)}`, binding.path);
1629
+ }
1630
+ if (acquired.kind === 'acquired' || acquired.code !== ENVIRONMENT_CODES.STATE_LOCKED)
1631
+ break;
1632
+ const remainingMs = deadline - Date.now();
1633
+ if (remainingMs <= 0 || stableOptions.contentionTimeoutMs === undefined)
1634
+ break;
1635
+ await delay(Math.min(backoffMs, remainingMs));
1636
+ backoffMs = Math.min(backoffMs * 2, 50);
1637
+ }
1638
+ if (acquired.kind !== 'acquired')
1639
+ return acquired;
1640
+ // The operation is the caller's own work, not a state record: its exception is rethrown
1641
+ // unchanged once the lock is released. Labelling it STATE_RECORD_MALFORMED sent the operator
1642
+ // to inspect state that was never touched.
1643
+ let outcome;
1644
+ try {
1645
+ outcome = { kind: 'complete', value: await operation() };
1646
+ }
1647
+ catch (error) {
1648
+ outcome = { kind: 'thrown', error };
1649
+ }
1650
+ try {
1651
+ await releaseLock(binding.path, acquired.ownerBytes, stableOptions.beforeRelease, stableOptions.beforeLockMetadata);
1652
+ }
1653
+ catch (error) {
1654
+ const release = error instanceof LockReleaseError ? error : undefined;
1655
+ const lockRecovery = release?.recovery ?? 'inspect the recorded scoped lock owner before explicit reclamation';
1656
+ // As in transactState: completed work survives a failed release, so the caller can
1657
+ // still act on it while the stuck lock is reported.
1658
+ if (outcome.kind === 'complete')
1659
+ return {
1660
+ kind: 'indeterminate',
1661
+ code: ENVIRONMENT_CODES.STATE_DURABILITY_INDETERMINATE,
1662
+ message: 'scoped state operation completed but its lock could not be released safely',
1663
+ target: release?.target ?? binding.path,
1664
+ recovery: lockRecovery,
1665
+ value: outcome.value,
1666
+ };
1667
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, `scoped state operation failed (${errorCause(outcome.error)}) and its lock could not be released safely`, release?.target ?? binding.path, lockRecovery);
1668
+ }
1669
+ if (outcome.kind === 'thrown')
1670
+ throw outcome.error;
1671
+ return outcome;
1672
+ }
1673
+ async function restoreOwnerEvidence(backupPath, ownerPath, expectedOwner, tombstone, beforeLockMetadata) {
1674
+ const backup = await lstat(backupPath);
1675
+ if (!backup.isFile() || backup.isSymbolicLink() || !samePrivateNode(backup, expectedOwner)) {
1676
+ throw new RefusalError('owner evidence backup changed before restoration');
1677
+ }
1678
+ try {
1679
+ await beforeLockMetadata?.('owner-restore-link');
1680
+ const root = lstatSync(dirname(tombstone));
1681
+ const lock = lstatSync(tombstone);
1682
+ if (!root.isDirectory() || root.isSymbolicLink() || !privatelyOwned(root)
1683
+ || !lock.isDirectory() || lock.isSymbolicLink() || !privatelyOwned(lock)
1684
+ || !samePrivateNode(lstatSync(backupPath), expectedOwner)) {
1685
+ throw new RefusalError('state lock path became unsafe before owner restoration');
1686
+ }
1687
+ await link(backupPath, ownerPath);
1688
+ }
1689
+ catch (error) {
1690
+ if (!hasErrorCode(error, 'EEXIST'))
1691
+ throw error;
1692
+ }
1693
+ const restored = await lstat(ownerPath);
1694
+ if (!restored.isFile() || restored.isSymbolicLink() || !samePrivateNode(restored, expectedOwner)) {
1695
+ throw new RefusalError('owner evidence path changed during restoration');
1696
+ }
1697
+ await syncDirectory(tombstone);
1698
+ }
1699
+ async function syncDirectory(directoryPath) {
1700
+ const directory = await open(directoryPath, 'r');
1701
+ try {
1702
+ await directory.sync();
1703
+ }
1704
+ finally {
1705
+ await directory.close();
1706
+ }
1707
+ }
1708
+ class TemporaryPublicationError extends RefusalError {
1709
+ target;
1710
+ constructor(target) {
1711
+ super('temporary state publication path changed; unverified bytes were preserved');
1712
+ this.target = target;
1713
+ }
1714
+ }
1715
+ function preparedRecordMatches(path, expectedNode, expectedBytes) {
1716
+ if (expectedBytes.byteLength > SERIALIZED_RECORD_BYTE_LIMIT)
1717
+ return false;
1718
+ let descriptor;
1719
+ try {
1720
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
1721
+ const opened = fstatSync(descriptor);
1722
+ const named = lstatSync(path);
1723
+ if (!privatelyOwned(opened)
1724
+ || !opened.isFile()
1725
+ || opened.isSymbolicLink()
1726
+ || !privatelyOwned(named)
1727
+ || !named.isFile()
1728
+ || named.isSymbolicLink()
1729
+ || !sameNode(opened, expectedNode)
1730
+ || !sameNode(named, expectedNode)
1731
+ || opened.size !== expectedBytes.byteLength
1732
+ || named.size !== expectedBytes.byteLength)
1733
+ return false;
1734
+ const bytes = Buffer.alloc(expectedBytes.byteLength + 1);
1735
+ let offset = 0;
1736
+ while (offset < bytes.byteLength) {
1737
+ const count = readSync(descriptor, bytes, offset, bytes.byteLength - offset, offset);
1738
+ if (count === 0)
1739
+ break;
1740
+ offset += count;
1741
+ }
1742
+ const openedAfter = fstatSync(descriptor);
1743
+ const namedAfter = lstatSync(path);
1744
+ if (offset !== expectedBytes.byteLength
1745
+ || !openedAfter.isFile()
1746
+ || openedAfter.isSymbolicLink()
1747
+ || !privatelyOwned(namedAfter)
1748
+ || !namedAfter.isFile()
1749
+ || namedAfter.isSymbolicLink()
1750
+ || !sameNode(openedAfter, expectedNode)
1751
+ || !sameNode(namedAfter, expectedNode)
1752
+ || openedAfter.size !== expectedBytes.byteLength
1753
+ || namedAfter.size !== expectedBytes.byteLength)
1754
+ return false;
1755
+ return bytes.subarray(0, offset).equals(expectedBytes);
1756
+ }
1757
+ catch {
1758
+ // A file that cannot be re-read proves nothing; the caller treats the mismatch as unsafe.
1759
+ return false;
1760
+ }
1761
+ finally {
1762
+ if (descriptor !== undefined)
1763
+ closeSync(descriptor);
1764
+ }
1765
+ }
1766
+ async function publishRecord(address, record, beforePublish, beforeCommit) {
1767
+ await ensureDirectory(dirname(address.path), address.stateRoot);
1768
+ const temporaryPath = join(dirname(address.path), `.${address.kind === 'bootstrap' ? address.target : identitySegment(address.subjectId)}.${randomUUID()}.tmp`);
1769
+ const source = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
1770
+ let temporaryExists = false;
1771
+ let temporaryNode;
1772
+ try {
1773
+ const handle = await open(temporaryPath, 'wx', 0o600);
1774
+ temporaryExists = true;
1775
+ try {
1776
+ if (source.byteLength > SERIALIZED_RECORD_BYTE_LIMIT)
1777
+ throw new RefusalError('state record exceeds the byte limit');
1778
+ await handle.writeFile(source);
1779
+ await handle.sync();
1780
+ temporaryNode = await handle.stat();
1781
+ }
1782
+ finally {
1783
+ await handle.close();
1784
+ }
1785
+ await beforePublish?.(temporaryPath);
1786
+ const currentTemporary = await lstat(temporaryPath);
1787
+ if (!temporaryNode || !currentTemporary.isFile() || currentTemporary.isSymbolicLink() || !sameNode(currentTemporary, temporaryNode)) {
1788
+ throw new TemporaryPublicationError(temporaryPath);
1789
+ }
1790
+ if (beforeCommit) {
1791
+ const cancellation = beforeCommit();
1792
+ if (cancellation !== undefined) {
1793
+ if (!isStateTransactionAbort(cancellation))
1794
+ throw new RefusalError('state commit veto is malformed');
1795
+ return cancellation;
1796
+ }
1797
+ }
1798
+ if (!temporaryNode || !preparedRecordMatches(temporaryPath, temporaryNode, source)) {
1799
+ throw new TemporaryPublicationError(temporaryPath);
1800
+ }
1801
+ let cursor = address.stateRoot;
1802
+ for (const segment of ['', ...relative(address.stateRoot, address.path).split(sep)]) {
1803
+ cursor = segment ? join(cursor, segment) : cursor;
1804
+ try {
1805
+ const status = lstatSync(cursor);
1806
+ if (!privatelyOwned(status) || status.isSymbolicLink() || (cursor === address.path ? !status.isFile() : !status.isDirectory())) {
1807
+ throw new RefusalError('unsafe state path before publication');
1808
+ }
1809
+ }
1810
+ catch (error) {
1811
+ if (cursor === address.path && hasErrorCode(error, 'ENOENT'))
1812
+ break;
1813
+ throw error;
1814
+ }
1815
+ }
1816
+ renameSync(temporaryPath, address.path);
1817
+ temporaryExists = false;
1818
+ try {
1819
+ await syncDirectory(dirname(address.path));
1820
+ }
1821
+ catch {
1822
+ // The rename already happened; the caller reports the durability as indeterminate and tells the user to re-read the record.
1823
+ return 'indeterminate';
1824
+ }
1825
+ return 'durable';
1826
+ }
1827
+ finally {
1828
+ if (temporaryExists) {
1829
+ try {
1830
+ if (!temporaryNode || !preparedRecordMatches(temporaryPath, temporaryNode, source)) {
1831
+ throw new TemporaryPublicationError(temporaryPath);
1832
+ }
1833
+ await unlink(temporaryPath);
1834
+ await syncDirectory(dirname(address.path));
1835
+ }
1836
+ catch (error) {
1837
+ if (!hasErrorCode(error, 'ENOENT'))
1838
+ throw error;
1839
+ }
1840
+ }
1841
+ }
1842
+ }
1843
+ export async function transactState(namespace, address, options, update) {
1844
+ let stableAddress;
1845
+ let stableNamespace;
1846
+ let stableOptions;
1847
+ try {
1848
+ if (typeof update !== 'function')
1849
+ throw new RefusalError('state transaction update is malformed');
1850
+ if (address.kind === 'bootstrap') {
1851
+ const authenticated = authenticateBootstrapStateNamespace(namespace);
1852
+ if (!authenticated)
1853
+ throw new RefusalError('bootstrap namespace is not issued');
1854
+ if (Reflect.ownKeys(address).sort().join(',') !== 'checkoutId,checkoutRoot,kind,path,stateRoot,target') {
1855
+ throw new RefusalError('bootstrap address shape is not closed');
1856
+ }
1857
+ stableAddress = { path: address.path, stateRoot: address.stateRoot,
1858
+ checkoutRoot: address.checkoutRoot, kind: 'bootstrap', checkoutId: address.checkoutId, target: address.target };
1859
+ stableNamespace = { root: authenticated.root, scope: 'bootstrap',
1860
+ checkoutRoot: authenticated.checkoutRoot, checkoutId: authenticated.checkoutId, target: authenticated.target,
1861
+ paths: { stateLock: authenticated.paths.stateLock } };
1862
+ }
1863
+ else {
1864
+ const namespacePaths = namespace.paths;
1865
+ const qualifier = address.qualifier;
1866
+ stableAddress = { path: address.path, stateRoot: address.stateRoot, cockpitRoot: address.cockpitRoot,
1867
+ kind: address.kind, cockpitId: address.cockpitId, subjectId: address.subjectId, ...(qualifier ? { qualifier } : {}) };
1868
+ stableNamespace = { root: namespace.root, scope: 'cockpit',
1869
+ cockpitRoot: namespace.cockpitRoot, paths: { stateLock: namespacePaths.stateLock } };
1870
+ }
1871
+ stableOptions = {
1872
+ ownerId: options.ownerId,
1873
+ contentionTimeoutMs: options.contentionTimeoutMs,
1874
+ observeHolder: options.observeHolder,
1875
+ beforePublish: options.beforePublish,
1876
+ beforeCommit: options.beforeCommit,
1877
+ beforeRelease: options.beforeRelease,
1878
+ beforeLockMetadata: options.beforeLockMetadata,
1879
+ };
1880
+ }
1881
+ catch (error) {
1882
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state transaction input could not be read safely: ${errorCause(error)}`, 'state transaction input');
1883
+ }
1884
+ const bootstrapTransaction = stableNamespace.scope === 'bootstrap' && stableAddress.kind === 'bootstrap';
1885
+ const ordinaryTransaction = stableNamespace.scope === 'cockpit' && stableAddress.kind !== 'bootstrap';
1886
+ const identityValid = stableAddress.kind === 'bootstrap'
1887
+ ? stableNamespace.scope === 'bootstrap'
1888
+ && stableAddress.checkoutRoot === stableNamespace.checkoutRoot
1889
+ && stableAddress.checkoutId === stableNamespace.checkoutId
1890
+ && stableAddress.target === stableNamespace.target
1891
+ && checkoutIdentity.test(stableAddress.checkoutId)
1892
+ && stableNamespace.paths.stateLock === join(stableNamespace.root, 'checkouts', identitySegment(stableAddress.checkoutId), 'locks', 'state.lock')
1893
+ : stableNamespace.scope === 'cockpit'
1894
+ && cockpitIdentity.test(stableAddress.cockpitId)
1895
+ && stableNamespace.cockpitRoot === join(stableNamespace.root, 'cockpits', identitySegment(stableAddress.cockpitId))
1896
+ && stableAddress.cockpitRoot === stableNamespace.cockpitRoot
1897
+ && stableNamespace.paths.stateLock === join(stableNamespace.cockpitRoot, 'locks', 'state.lock');
1898
+ if (!isAbsolute(stableNamespace.root) || stableAddress.stateRoot !== stableNamespace.root
1899
+ || !(await addressIsSafe(stableAddress))
1900
+ || !identityValid || !bootstrapTransaction && !ordinaryTransaction
1901
+ || stableOptions.contentionTimeoutMs !== undefined && (!Number.isSafeInteger(stableOptions.contentionTimeoutMs)
1902
+ || stableOptions.contentionTimeoutMs < 0
1903
+ || stableOptions.contentionTimeoutMs > 30_000)) {
1904
+ return malformed(stableAddress);
1905
+ }
1906
+ let acquired;
1907
+ const deadline = Date.now() + (stableOptions.contentionTimeoutMs ?? 0);
1908
+ let backoffMs = 5;
1909
+ while (true) {
1910
+ try {
1911
+ acquired = await acquireLock(stableNamespace, stableOptions, bootstrapTransaction ? bootstrapHolderIdentity : holderIdentity);
1912
+ }
1913
+ catch (error) {
1914
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, `state lock could not be acquired safely: ${errorCause(error)}`, stableNamespace.paths.stateLock);
1915
+ }
1916
+ if (acquired.kind === 'acquired' || acquired.code !== ENVIRONMENT_CODES.STATE_LOCKED)
1917
+ break;
1918
+ const remainingMs = deadline - Date.now();
1919
+ if (remainingMs <= 0 || stableOptions.contentionTimeoutMs === undefined)
1920
+ break;
1921
+ await delay(Math.min(backoffMs, remainingMs));
1922
+ backoffMs = Math.min(backoffMs * 2, 50);
1923
+ }
1924
+ if (acquired.kind !== 'acquired')
1925
+ return acquired;
1926
+ let result;
1927
+ let operationFailure;
1928
+ let operationFailed = false;
1929
+ try {
1930
+ const currentResult = await readState(stableAddress);
1931
+ if (currentResult.kind === 'refusal') {
1932
+ result = currentResult;
1933
+ }
1934
+ else {
1935
+ const current = currentResult.kind === 'complete' ? currentResult.value : null;
1936
+ let proposed;
1937
+ try {
1938
+ proposed = await update(current);
1939
+ }
1940
+ catch (error) {
1941
+ operationFailure = error;
1942
+ operationFailed = true;
1943
+ }
1944
+ if (operationFailed) {
1945
+ // The owner failed before proposing state, so retain its error until the lock is released.
1946
+ result = malformed(stableAddress);
1947
+ }
1948
+ else
1949
+ try {
1950
+ if (isStateTransactionAbort(proposed)) {
1951
+ result = proposed;
1952
+ }
1953
+ else {
1954
+ const record = validateRecordForAddress(stableAddress, proposed, current);
1955
+ if (!record) {
1956
+ result = malformed(stableAddress);
1957
+ }
1958
+ else {
1959
+ const publication = await publishRecord(stableAddress, record, stableOptions.beforePublish, stableOptions.beforeCommit);
1960
+ if (isStateTransactionAbort(publication)) {
1961
+ result = publication;
1962
+ }
1963
+ else {
1964
+ result = publication === 'durable'
1965
+ ? { kind: 'complete', value: record }
1966
+ : {
1967
+ kind: 'indeterminate',
1968
+ code: ENVIRONMENT_CODES.STATE_DURABILITY_INDETERMINATE,
1969
+ message: 'state bytes were renamed but parent directory durability could not be confirmed',
1970
+ target: stableAddress.path,
1971
+ recovery: 'read the record before deciding whether to retry the transaction',
1972
+ value: record,
1973
+ };
1974
+ }
1975
+ }
1976
+ }
1977
+ }
1978
+ catch (error) {
1979
+ result = error instanceof TemporaryPublicationError
1980
+ ? refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, error.message, error.target, 'inspect and remove the preserved temporary path only after proving its ownership')
1981
+ : malformed(stableAddress, error);
1982
+ }
1983
+ }
1984
+ }
1985
+ catch (error) {
1986
+ result = malformed(stableAddress, error);
1987
+ }
1988
+ try {
1989
+ await releaseLock(stableNamespace.paths.stateLock, acquired.ownerBytes, stableOptions.beforeRelease, stableOptions.beforeLockMetadata);
1990
+ }
1991
+ catch (error) {
1992
+ const release = error instanceof LockReleaseError ? error : undefined;
1993
+ if (operationFailed) {
1994
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, `state transaction operation failed (${errorCause(operationFailure)}) and its lock could not be released safely`, release?.target ?? stableNamespace.paths.stateLock, release?.recovery ?? 'inspect the recorded lock owner before explicit reclamation');
1995
+ }
1996
+ if ('kind' in result && (result.kind === 'complete' || result.kind === 'indeterminate')) {
1997
+ const publicationRecovery = result.kind === 'indeterminate'
1998
+ ? result.recovery
1999
+ : 'read the committed record and reconcile its revision before deciding whether to retry the transaction';
2000
+ const lockRecovery = release?.recovery
2001
+ ?? 'inspect the recorded lock owner before explicit reclamation';
2002
+ return issueStatePublication({
2003
+ kind: 'indeterminate',
2004
+ code: ENVIRONMENT_CODES.STATE_DURABILITY_INDETERMINATE,
2005
+ message: result.kind === 'indeterminate'
2006
+ ? `${result.message}; the state lock also could not be released safely`
2007
+ : 'state bytes were committed but the state lock could not be released safely',
2008
+ target: stableAddress.path,
2009
+ recovery: `${publicationRecovery}; ${lockRecovery}`,
2010
+ value: result.value,
2011
+ }, stableAddress);
2012
+ }
2013
+ return refusal(ENVIRONMENT_CODES.STATE_LOCKED, 'state transaction completed but its lock could not be released safely', release?.target ?? stableNamespace.paths.stateLock, release?.recovery ?? 'inspect the recorded lock owner before explicit reclamation');
2014
+ }
2015
+ if (operationFailed)
2016
+ throw operationFailure;
2017
+ return issueStatePublication(result, stableAddress);
2018
+ }
2019
+ function stackStatePaths(value, stackId) {
2020
+ return [
2021
+ { kind: 'coordination', path: value.paths.coordination(stackId).path },
2022
+ { kind: 'lifecycle', path: value.paths.lifecycle(stackId).path },
2023
+ { kind: 'lifecycle-history', path: lifecycleHistoryAddress(value, stackId).path },
2024
+ { kind: 'data-outcome', path: value.paths.dataOutcome(stackId).path },
2025
+ ];
2026
+ }
2027
+ async function readStackStateFiles(value, stackId) {
2028
+ const files = [];
2029
+ let coordination;
2030
+ for (const { kind, path } of stackStatePaths(value, stackId)) {
2031
+ let bytes;
2032
+ // Bytes, not parsed records: a record this version cannot parse is still this stack's and is
2033
+ // bound by its digest like any other.
2034
+ try {
2035
+ bytes = await readRegularFileNoFollow(path, () => ownedPathIsSafe(value.root, path, 'file'));
2036
+ }
2037
+ catch (error) {
2038
+ if (hasErrorCode(error, 'ENOENT'))
2039
+ continue;
2040
+ throw error;
2041
+ }
2042
+ files.push({ kind, path, sha256: sha256Hex(bytes) });
2043
+ if (kind === 'coordination')
2044
+ coordination = decodeStateRecord(value.paths.coordination(stackId), bytes);
2045
+ }
2046
+ return { files, coordination };
2047
+ }
2048
+ /** The record files a stack has under one cockpit's namespace, read as bytes so an unparseable record is listed too. */
2049
+ export async function inspectStackState(value, stackId) {
2050
+ const read = await inspectStackRecords(value, stackId);
2051
+ return read.kind === 'complete' || read.kind === 'indeterminate' ? { ...read, value: read.value.files } : read;
2052
+ }
2053
+ /** `inspectStackState`, plus the coordination record decoded from the bytes it read rather than from a second read. */
2054
+ export async function inspectStackRecords(value, stackId) {
2055
+ const authenticated = authenticateStateNamespace(value);
2056
+ if (!authenticated || !stackIdentity.test(stackId))
2057
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'stack state inspection requires an issued namespace and a stack id', 'stack state');
2058
+ try {
2059
+ return { kind: 'complete', value: await readStackStateFiles(authenticated, stackId) };
2060
+ }
2061
+ catch (error) {
2062
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `stack state could not be read safely: ${errorCause(error)}`, authenticated.cockpitRoot);
2063
+ }
2064
+ }
2065
+ /**
2066
+ * Deletes a stack's record files, which releases its port lease, only when each file still holds the
2067
+ * exact bytes `expected` names and no other file has appeared. It runs under the root port lock, so
2068
+ * no `up` leases ports from a view that still counts the lease, and under the cockpit's own state
2069
+ * lock, so no transaction rewrites a record between the check and the removal.
2070
+ */
2071
+ export async function removeStackState(value, stackId, expected, options) {
2072
+ const authenticated = authenticateStateNamespace(value);
2073
+ if (!authenticated || !stackIdentity.test(stackId) || !holderIdentity.test(options.ownerId)) {
2074
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'stack state removal requires an issued namespace, a stack id and a holder', 'stack state');
2075
+ }
2076
+ const scope = { kind: 'state-scoped-lock' };
2077
+ issuedScopedLocks.set(scope, Object.freeze({ namespace: authenticated, path: authenticated.paths.stateLock }));
2078
+ const key = (files) => files.map(({ kind, path, sha256 }) => `${kind}\0${path}\0${sha256}`).sort().join('\n');
2079
+ try {
2080
+ const locked = await withStateRootPortLock(authenticated, { ownerId: options.ownerId, contentionTimeoutMs: 2_000 }, () => withStateScopedLock(authenticated, scope, { ownerId: options.ownerId, contentionTimeoutMs: 2_000 }, async () => {
2081
+ const { files: current } = await readStackStateFiles(authenticated, stackId);
2082
+ if (key(current) !== key(expected))
2083
+ return { kind: 'changed', reason: `the state of ${stackId} changed since it was previewed` };
2084
+ for (const { path } of current)
2085
+ await unlink(path);
2086
+ for (const directory of new Set(current.map(({ path }) => dirname(path))))
2087
+ await syncDirectory(directory);
2088
+ return { kind: 'removed', paths: current.map(({ path }) => path) };
2089
+ }));
2090
+ return locked.kind === 'complete' ? locked.value : locked;
2091
+ }
2092
+ catch (error) {
2093
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `stack state could not be removed safely: ${errorCause(error)}`, authenticated.cockpitRoot);
2094
+ }
2095
+ }
2096
+ const NAMESPACE_FINGERPRINT_ENTRIES = 4_096;
2097
+ async function namespaceFingerprint(path) {
2098
+ const hash = createHash('sha256');
2099
+ let entries = 0;
2100
+ const walk = async (directory) => {
2101
+ for (const name of (await readdir(directory)).sort()) {
2102
+ if (++entries > NAMESPACE_FINGERPRINT_ENTRIES)
2103
+ throw new RefusalError('state folder exceeds its entry boundary');
2104
+ const child = join(directory, name);
2105
+ const status = await lstat(child, { bigint: true });
2106
+ if (!privatelyOwned({ uid: Number(status.uid), mode: Number(status.mode) }))
2107
+ throw new RefusalError(`${child} is not privately owned`);
2108
+ hash.update(`${relative(path, child)}\0${status.mode}\0${status.size}\0${status.mtimeNs}\n`);
2109
+ if (status.isDirectory())
2110
+ await walk(child);
2111
+ }
2112
+ };
2113
+ const status = await lstat(path);
2114
+ if (status.isSymbolicLink() || !status.isDirectory() || !privatelyOwned(status))
2115
+ throw new RefusalError(`${path} is not a private directory`);
2116
+ await walk(path);
2117
+ return hash.digest('hex');
2118
+ }
2119
+ function otherNamespacePath(value, path) {
2120
+ return typeof path === 'string' && isAbsolute(path) && dirname(path) === join(value.root, 'cockpits')
2121
+ && path !== value.cockpitRoot && !basename(path).startsWith('.');
2122
+ }
2123
+ /** A digest of another cockpit's state folder that this version cannot read: every entry's path, mode, size and modification time. */
2124
+ export async function inspectStateNamespaceFolder(value, path) {
2125
+ const authenticated = authenticateStateNamespace(value);
2126
+ if (!authenticated || !otherNamespacePath(authenticated, path))
2127
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'only another cockpit\'s state folder can be inspected for removal', String(path));
2128
+ try {
2129
+ return { kind: 'complete', value: await namespaceFingerprint(path) };
2130
+ }
2131
+ catch (error) {
2132
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state folder could not be inspected: ${errorCause(error)}`, path);
2133
+ }
2134
+ }
2135
+ /** Deletes another cockpit's unreadable state folder, under the root port lock, only while it still matches `fingerprint`. */
2136
+ export async function removeStateNamespaceFolder(value, path, fingerprint, options) {
2137
+ const authenticated = authenticateStateNamespace(value);
2138
+ if (!authenticated || !otherNamespacePath(authenticated, path) || !digest.test(fingerprint) || !holderIdentity.test(options.ownerId)) {
2139
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'only another cockpit\'s previewed state folder can be removed', String(path));
2140
+ }
2141
+ try {
2142
+ return await withStateRootPortLock(authenticated, { ownerId: options.ownerId, contentionTimeoutMs: 2_000 }, async () => {
2143
+ if (await namespaceFingerprint(path) !== fingerprint)
2144
+ return { kind: 'changed', reason: `${path} changed since it was previewed` };
2145
+ await rm(path, { recursive: true });
2146
+ await syncDirectory(dirname(path));
2147
+ return { kind: 'removed', paths: [path] };
2148
+ });
2149
+ }
2150
+ catch (error) {
2151
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state folder could not be removed safely: ${errorCause(error)}`, path);
2152
+ }
2153
+ }