@b00dea/cockpit 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -1
  4. package/bin/cockpit.mjs +33 -0
  5. package/cockpit.schema.json +2574 -0
  6. package/dist/autonomy.d.ts +44 -0
  7. package/dist/autonomy.js +156 -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 +62 -0
  11. package/dist/browser/commands.js +349 -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 +117 -0
  17. package/dist/browser/runtime.js +183 -0
  18. package/dist/browser/surfaces.d.ts +141 -0
  19. package/dist/browser/surfaces.js +556 -0
  20. package/dist/browser/ui-gate.d.ts +15 -0
  21. package/dist/browser/ui-gate.js +18 -0
  22. package/dist/cli-guard.d.ts +5 -0
  23. package/dist/cli-guard.js +71 -0
  24. package/dist/cli-main.d.ts +139 -0
  25. package/dist/cli-main.js +1405 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.js +16 -0
  28. package/dist/command-table.d.ts +113 -0
  29. package/dist/command-table.js +512 -0
  30. package/dist/config/load.d.ts +171 -0
  31. package/dist/config/load.js +345 -0
  32. package/dist/config/sibling-toml.d.ts +48 -0
  33. package/dist/config/sibling-toml.js +160 -0
  34. package/dist/config/surface-tools-toml.d.ts +9 -0
  35. package/dist/config/surface-tools-toml.js +37 -0
  36. package/dist/config/verified-write.d.ts +43 -0
  37. package/dist/config/verified-write.js +89 -0
  38. package/dist/diagnostics.d.ts +26 -0
  39. package/dist/diagnostics.js +335 -0
  40. package/dist/emit.d.ts +22 -0
  41. package/dist/emit.js +43 -0
  42. package/dist/entrypoint-kinds.d.ts +3 -0
  43. package/dist/entrypoint-kinds.js +11 -0
  44. package/dist/environment/application/destructive.d.ts +6 -0
  45. package/dist/environment/application/destructive.js +425 -0
  46. package/dist/environment/application/doctor.d.ts +89 -0
  47. package/dist/environment/application/doctor.js +235 -0
  48. package/dist/environment/application/guard.d.ts +43 -0
  49. package/dist/environment/application/guard.js +622 -0
  50. package/dist/environment/application/health.d.ts +134 -0
  51. package/dist/environment/application/health.js +379 -0
  52. package/dist/environment/application/lifecycle-context.d.ts +228 -0
  53. package/dist/environment/application/lifecycle-context.js +154 -0
  54. package/dist/environment/application/lifecycle-state.d.ts +25 -0
  55. package/dist/environment/application/lifecycle-state.js +201 -0
  56. package/dist/environment/application/lifecycle.d.ts +17 -0
  57. package/dist/environment/application/lifecycle.js +729 -0
  58. package/dist/environment/application/logs.d.ts +45 -0
  59. package/dist/environment/application/logs.js +127 -0
  60. package/dist/environment/application/setup.d.ts +129 -0
  61. package/dist/environment/application/setup.js +741 -0
  62. package/dist/environment/application/shared.d.ts +11 -0
  63. package/dist/environment/application/shared.js +61 -0
  64. package/dist/environment/claims.d.ts +389 -0
  65. package/dist/environment/claims.js +1709 -0
  66. package/dist/environment/codex-hook-trust.d.ts +11 -0
  67. package/dist/environment/codex-hook-trust.js +185 -0
  68. package/dist/environment/compose-environment.d.ts +1 -0
  69. package/dist/environment/compose-environment.js +6 -0
  70. package/dist/environment/compose-ownership.d.ts +21 -0
  71. package/dist/environment/compose-ownership.js +117 -0
  72. package/dist/environment/compose-topology.d.ts +117 -0
  73. package/dist/environment/compose-topology.js +413 -0
  74. package/dist/environment/contract.d.ts +489 -0
  75. package/dist/environment/contract.js +761 -0
  76. package/dist/environment/declaration.d.ts +14 -0
  77. package/dist/environment/declaration.js +39 -0
  78. package/dist/environment/destructive-composition.d.ts +29 -0
  79. package/dist/environment/destructive-composition.js +333 -0
  80. package/dist/environment/docker-daemon.d.ts +30 -0
  81. package/dist/environment/docker-daemon.js +78 -0
  82. package/dist/environment/docker-endpoint.d.ts +10 -0
  83. package/dist/environment/docker-endpoint.js +19 -0
  84. package/dist/environment/env-schema-observation.d.ts +46 -0
  85. package/dist/environment/env-schema-observation.js +241 -0
  86. package/dist/environment/evidence.d.ts +136 -0
  87. package/dist/environment/evidence.js +437 -0
  88. package/dist/environment/executable.d.ts +26 -0
  89. package/dist/environment/executable.js +167 -0
  90. package/dist/environment/generated-application/authorities.d.ts +5 -0
  91. package/dist/environment/generated-application/authorities.js +61 -0
  92. package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
  93. package/dist/environment/generated-application/bootstrap-skill.js +328 -0
  94. package/dist/environment/generated-application/check.d.ts +13 -0
  95. package/dist/environment/generated-application/check.js +189 -0
  96. package/dist/environment/generated-application/custom-surface.d.ts +26 -0
  97. package/dist/environment/generated-application/custom-surface.js +289 -0
  98. package/dist/environment/generated-application/prepare.d.ts +29 -0
  99. package/dist/environment/generated-application/prepare.js +276 -0
  100. package/dist/environment/generated-application/publication.d.ts +33 -0
  101. package/dist/environment/generated-application/publication.js +547 -0
  102. package/dist/environment/generated-application/shared.d.ts +484 -0
  103. package/dist/environment/generated-application/shared.js +349 -0
  104. package/dist/environment/generated-application/surface-observation.d.ts +4 -0
  105. package/dist/environment/generated-application/surface-observation.js +69 -0
  106. package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
  107. package/dist/environment/generated-application/surface-outputs.js +389 -0
  108. package/dist/environment/generated-application/surfaces.d.ts +3 -0
  109. package/dist/environment/generated-application/surfaces.js +699 -0
  110. package/dist/environment/generated-application/sync.d.ts +6 -0
  111. package/dist/environment/generated-application/sync.js +109 -0
  112. package/dist/environment/git-observation.d.ts +183 -0
  113. package/dist/environment/git-observation.js +544 -0
  114. package/dist/environment/health-composition.d.ts +11 -0
  115. package/dist/environment/health-composition.js +628 -0
  116. package/dist/environment/host-observation.d.ts +56 -0
  117. package/dist/environment/host-observation.js +52 -0
  118. package/dist/environment/identity.d.ts +48 -0
  119. package/dist/environment/identity.js +85 -0
  120. package/dist/environment/init-application.d.ts +308 -0
  121. package/dist/environment/init-application.js +697 -0
  122. package/dist/environment/install-policy-observation.d.ts +71 -0
  123. package/dist/environment/install-policy-observation.js +284 -0
  124. package/dist/environment/install-policy.d.ts +48 -0
  125. package/dist/environment/install-policy.js +96 -0
  126. package/dist/environment/instance.d.ts +33 -0
  127. package/dist/environment/instance.js +122 -0
  128. package/dist/environment/instruction-pointer.d.ts +19 -0
  129. package/dist/environment/instruction-pointer.js +144 -0
  130. package/dist/environment/lifecycle-composition.d.ts +29 -0
  131. package/dist/environment/lifecycle-composition.js +675 -0
  132. package/dist/environment/lifecycle-stage.d.ts +115 -0
  133. package/dist/environment/lifecycle-stage.js +398 -0
  134. package/dist/environment/lifecycle.d.ts +75 -0
  135. package/dist/environment/lifecycle.js +354 -0
  136. package/dist/environment/logs.d.ts +132 -0
  137. package/dist/environment/logs.js +519 -0
  138. package/dist/environment/observations.d.ts +46 -0
  139. package/dist/environment/observations.js +344 -0
  140. package/dist/environment/operator-application.d.ts +50 -0
  141. package/dist/environment/operator-application.js +155 -0
  142. package/dist/environment/ports.d.ts +182 -0
  143. package/dist/environment/ports.js +1205 -0
  144. package/dist/environment/posture-observation.d.ts +68 -0
  145. package/dist/environment/posture-observation.js +253 -0
  146. package/dist/environment/read-composition.d.ts +27 -0
  147. package/dist/environment/read-composition.js +352 -0
  148. package/dist/environment/readiness-composition.d.ts +38 -0
  149. package/dist/environment/readiness-composition.js +130 -0
  150. package/dist/environment/reclaim.d.ts +333 -0
  151. package/dist/environment/reclaim.js +1835 -0
  152. package/dist/environment/repository-identity.d.ts +13 -0
  153. package/dist/environment/repository-identity.js +108 -0
  154. package/dist/environment/runtime-measurement-record.d.ts +38 -0
  155. package/dist/environment/runtime-measurement-record.js +111 -0
  156. package/dist/environment/runtime-measurement.d.ts +27 -0
  157. package/dist/environment/runtime-measurement.js +224 -0
  158. package/dist/environment/runtime-probe-residue.d.ts +43 -0
  159. package/dist/environment/runtime-probe-residue.js +148 -0
  160. package/dist/environment/runtime-probe.d.ts +72 -0
  161. package/dist/environment/runtime-probe.js +725 -0
  162. package/dist/environment/runtime.d.ts +505 -0
  163. package/dist/environment/runtime.js +2335 -0
  164. package/dist/environment/seed.d.ts +16 -0
  165. package/dist/environment/seed.js +43 -0
  166. package/dist/environment/selftest-application.d.ts +86 -0
  167. package/dist/environment/selftest-application.js +713 -0
  168. package/dist/environment/sensitive.d.ts +3 -0
  169. package/dist/environment/sensitive.js +48 -0
  170. package/dist/environment/setup-observation.d.ts +62 -0
  171. package/dist/environment/setup-observation.js +1473 -0
  172. package/dist/environment/setup.d.ts +43 -0
  173. package/dist/environment/setup.js +176 -0
  174. package/dist/environment/sibling-instructions.d.ts +33 -0
  175. package/dist/environment/sibling-instructions.js +224 -0
  176. package/dist/environment/sibling-registration.d.ts +45 -0
  177. package/dist/environment/sibling-registration.js +538 -0
  178. package/dist/environment/state.d.ts +371 -0
  179. package/dist/environment/state.js +2013 -0
  180. package/dist/environment/templates.d.ts +140 -0
  181. package/dist/environment/templates.js +685 -0
  182. package/dist/environment/tool-registration.d.ts +9 -0
  183. package/dist/environment/tool-registration.js +96 -0
  184. package/dist/environment/upgrade-application.d.ts +19 -0
  185. package/dist/environment/upgrade-application.js +175 -0
  186. package/dist/environment-exec/execute.d.ts +102 -0
  187. package/dist/environment-exec/execute.js +1175 -0
  188. package/dist/environment-exec/libexec-program.d.ts +6 -0
  189. package/dist/environment-exec/libexec-program.js +22 -0
  190. package/dist/environment-exec/verified-digest.d.ts +4 -0
  191. package/dist/environment-exec/verified-digest.js +16 -0
  192. package/dist/exact-keys.d.ts +7 -0
  193. package/dist/exact-keys.js +15 -0
  194. package/dist/fix-catalogue.d.ts +32 -0
  195. package/dist/fix-catalogue.js +60 -0
  196. package/dist/freshness.d.ts +29 -0
  197. package/dist/freshness.js +75 -0
  198. package/dist/generated-environment/adopt.d.ts +16 -0
  199. package/dist/generated-environment/adopt.js +275 -0
  200. package/dist/generated-environment/command.d.ts +47 -0
  201. package/dist/generated-environment/command.js +75 -0
  202. package/dist/generated-environment/transaction.d.ts +186 -0
  203. package/dist/generated-environment/transaction.js +1770 -0
  204. package/dist/grading/evaluate.d.ts +71 -0
  205. package/dist/grading/evaluate.js +403 -0
  206. package/dist/grading/grade.d.ts +33 -0
  207. package/dist/grading/grade.js +57 -0
  208. package/dist/guards/evaluator.d.ts +113 -0
  209. package/dist/guards/evaluator.js +1226 -0
  210. package/dist/guards/harness-adapter.d.ts +17 -0
  211. package/dist/guards/harness-adapter.js +57 -0
  212. package/dist/guards/host.d.ts +8 -0
  213. package/dist/guards/host.js +10 -0
  214. package/dist/guards/invocation-parser.d.ts +66 -0
  215. package/dist/guards/invocation-parser.js +595 -0
  216. package/dist/guards/local-identity.d.ts +40 -0
  217. package/dist/guards/local-identity.js +123 -0
  218. package/dist/guards/policy-pin.d.ts +13 -0
  219. package/dist/guards/policy-pin.js +175 -0
  220. package/dist/guards/protected-locations.d.ts +62 -0
  221. package/dist/guards/protected-locations.js +461 -0
  222. package/dist/guards/read-only.d.ts +37 -0
  223. package/dist/guards/read-only.js +315 -0
  224. package/dist/init/command.d.ts +40 -0
  225. package/dist/init/command.js +310 -0
  226. package/dist/init/derive.d.ts +51 -0
  227. package/dist/init/derive.js +715 -0
  228. package/dist/init/draft.d.ts +138 -0
  229. package/dist/init/draft.js +1205 -0
  230. package/dist/init/render.d.ts +35 -0
  231. package/dist/init/render.js +82 -0
  232. package/dist/init/scaffold.d.ts +53 -0
  233. package/dist/init/scaffold.js +121 -0
  234. package/dist/invocation.d.ts +13 -0
  235. package/dist/invocation.js +20 -0
  236. package/dist/package-metadata.d.ts +9 -0
  237. package/dist/package-metadata.js +19 -0
  238. package/dist/readiness.d.ts +53 -0
  239. package/dist/readiness.js +88 -0
  240. package/dist/references/named.d.ts +24 -0
  241. package/dist/references/named.js +27 -0
  242. package/dist/refusal.d.ts +15 -0
  243. package/dist/refusal.js +7 -0
  244. package/dist/registry-derivation.d.ts +31 -0
  245. package/dist/registry-derivation.js +32 -0
  246. package/dist/registry-resolve/command.d.ts +47 -0
  247. package/dist/registry-resolve/command.js +226 -0
  248. package/dist/registry-resolve/registry.d.ts +68 -0
  249. package/dist/registry-resolve/registry.js +292 -0
  250. package/dist/registry-resolve/render-shell.d.ts +18 -0
  251. package/dist/registry-resolve/render-shell.js +36 -0
  252. package/dist/repair.d.ts +29 -0
  253. package/dist/repair.js +8 -0
  254. package/dist/schema-json.d.ts +1 -0
  255. package/dist/schema-json.js +177 -0
  256. package/dist/schema.d.ts +1322 -0
  257. package/dist/schema.js +934 -0
  258. package/dist/setup/command.d.ts +4 -0
  259. package/dist/setup/command.js +4 -0
  260. package/dist/sha256.d.ts +1 -0
  261. package/dist/sha256.js +4 -0
  262. package/dist/surfaces/adoption.d.ts +41 -0
  263. package/dist/surfaces/adoption.js +217 -0
  264. package/dist/surfaces/consumer-instructions.d.ts +20 -0
  265. package/dist/surfaces/consumer-instructions.js +43 -0
  266. package/dist/surfaces/contract.d.ts +42 -0
  267. package/dist/surfaces/contract.js +169 -0
  268. package/dist/surfaces/core-declarations.d.ts +24 -0
  269. package/dist/surfaces/core-declarations.js +79 -0
  270. package/dist/surfaces/development-markers.d.ts +1 -0
  271. package/dist/surfaces/development-markers.js +18 -0
  272. package/dist/surfaces/generator-adapter.d.ts +71 -0
  273. package/dist/surfaces/generator-adapter.js +809 -0
  274. package/dist/surfaces/harness-registry.d.ts +180 -0
  275. package/dist/surfaces/harness-registry.js +117 -0
  276. package/dist/surfaces/mcp.d.ts +95 -0
  277. package/dist/surfaces/mcp.js +186 -0
  278. package/dist/surfaces/merge.d.ts +30 -0
  279. package/dist/surfaces/merge.js +105 -0
  280. package/dist/surfaces/ownership.d.ts +190 -0
  281. package/dist/surfaces/ownership.js +795 -0
  282. package/dist/surfaces/projection.d.ts +136 -0
  283. package/dist/surfaces/projection.js +681 -0
  284. package/dist/surfaces/render.d.ts +118 -0
  285. package/dist/surfaces/render.js +633 -0
  286. package/dist/surfaces/restrictions.d.ts +130 -0
  287. package/dist/surfaces/restrictions.js +447 -0
  288. package/dist/surfaces/selftest.d.ts +162 -0
  289. package/dist/surfaces/selftest.js +393 -0
  290. package/dist/surfaces/skill-contract.d.ts +85 -0
  291. package/dist/surfaces/skill-contract.js +449 -0
  292. package/dist/surfaces/sync.d.ts +57 -0
  293. package/dist/surfaces/sync.js +164 -0
  294. package/dist/team-metadata.d.ts +41 -0
  295. package/dist/team-metadata.js +512 -0
  296. package/dist/testing/gates.d.ts +91 -0
  297. package/dist/testing/gates.js +182 -0
  298. package/dist/testing/hooks.d.ts +59 -0
  299. package/dist/testing/hooks.js +139 -0
  300. package/dist/testing/verdict.d.ts +29 -0
  301. package/dist/testing/verdict.js +62 -0
  302. package/dist/work-orders/application.d.ts +99 -0
  303. package/dist/work-orders/application.js +383 -0
  304. package/dist/work-orders/composition.d.ts +124 -0
  305. package/dist/work-orders/composition.js +1010 -0
  306. package/dist/work-orders/contract.d.ts +61 -0
  307. package/dist/work-orders/contract.js +122 -0
  308. package/dist/work-orders/store.d.ts +158 -0
  309. package/dist/work-orders/store.js +197 -0
  310. package/docs/README.md +65 -0
  311. package/docs/architecture.md +136 -0
  312. package/docs/capability-matrix.md +50 -0
  313. package/docs/cli-reference.md +599 -0
  314. package/docs/command-list.md +51 -0
  315. package/docs/compose-patterns.md +99 -0
  316. package/docs/concepts.md +171 -0
  317. package/docs/configuration-essentials.md +172 -0
  318. package/docs/configuration-reference.md +621 -0
  319. package/docs/environment.md +760 -0
  320. package/docs/extending.md +43 -0
  321. package/docs/faq.md +111 -0
  322. package/docs/getting-started.md +608 -0
  323. package/docs/harness-surfaces.md +433 -0
  324. package/docs/joining-a-team.md +76 -0
  325. package/docs/release-policy.md +49 -0
  326. package/docs/safety-and-autonomy.md +419 -0
  327. package/docs/troubleshooting.md +361 -0
  328. package/docs/tutorial.md +170 -0
  329. package/docs/work-and-browser.md +254 -0
  330. package/libexec/browser-operation.cjs +186 -0
  331. package/libexec/fence-marker.cjs +101 -0
  332. package/libexec/fence-marker.d.cts +24 -0
  333. package/libexec/fenced-group-runner.cjs +77 -0
  334. package/libexec/fenced-supervisor.cjs +137 -0
  335. package/libexec/generated-filesystem.cjs +731 -0
  336. package/libexec/install-filesystem.cjs +225 -0
  337. package/libexec/rulesync-generator.NOTICE.md +1112 -0
  338. package/libexec/rulesync-generator.cjs +743 -0
  339. package/libexec/surface-generator-worker.cjs +266 -0
  340. package/libexec/surface-output-reader.cjs +99 -0
  341. package/libexec/surface-scratch-cleanup.cjs +14 -0
  342. package/libexec/validate-harness-evidence.d.mts +183 -0
  343. package/libexec/validate-harness-evidence.mjs +1312 -0
  344. package/package.json +76 -1
  345. package/surfaces/README.md +33 -0
  346. package/surfaces/agents/evaluator.agent.md +76 -0
  347. package/surfaces/guards/bash-guard.sh +48 -0
  348. package/surfaces/guards/claude.adapter.json +25 -0
  349. package/surfaces/guards/codex.adapter.json +24 -0
  350. package/surfaces/guards/opencode.adapter.json +25 -0
  351. package/surfaces/references/browser.md +63 -0
  352. package/surfaces/references/test-data-and-personas.md +84 -0
  353. package/surfaces/references/testing-tiers.md +11 -0
  354. package/surfaces/skills/cockpit-browser.skill.md +54 -0
  355. package/surfaces/skills/cockpit-explain.skill.md +32 -0
  356. package/surfaces/skills/cockpit-fix.skill.md +37 -0
  357. package/surfaces/skills/cockpit-setup.skill.md +39 -0
  358. package/surfaces/skills/cockpit-work.skill.md +34 -0
  359. package/surfaces/skills/collection.json +5 -0
  360. package/surfaces/skills/procedures/explain.md +61 -0
  361. package/surfaces/skills/procedures/fix.md +81 -0
  362. package/surfaces/skills/procedures/setup.md +379 -0
  363. package/surfaces/skills/procedures/work.md +105 -0
@@ -0,0 +1,254 @@
1
+ # Work, grading, browser and testing
2
+
3
+ This page is for anyone deciding whether a change needs a direct fix or a durable
4
+ [work order](concepts.md#work-order), and then getting it graded, checked in a browser, and proven
5
+ with the right test tier.
6
+
7
+ Each command's `--help` is its current contract. Most changes are fixes: the `cockpit-fix` skill
8
+ proves one with the declared test gate before and after, with no order. Write a work order when the
9
+ result must be held to criteria someone else set, graded by something other than the agent that did
10
+ the work, or carried across sessions. The `cockpit-work` skill walks an agent through the loop below;
11
+ the `cockpit-browser` skill runs the commands in [Browser evidence](#browser-evidence).
12
+
13
+ ## Write a work order
14
+
15
+ A work order is `workorders/<id>.md`. Its YAML front-matter is the whole contract cockpit accepts,
16
+ checks and grades; the Markdown body below it is yours, for notes and a plan, and cockpit never reads
17
+ it.
18
+
19
+ ```markdown
20
+ ---
21
+ id: WO-1
22
+ objective: Signing in with an expired session sends the person back to where they were.
23
+ scope:
24
+ - src/auth/
25
+ - tests/auth/**
26
+ - web:src/routes/login.ts
27
+ depends_on: []
28
+ gates: [unit]
29
+ criteria:
30
+ - id: AC1
31
+ text: After sign-in, a person whose session expired returns to the page they asked for.
32
+ check: { gate: unit, select: "test:returns to the requested page" }
33
+ - id: AC2
34
+ text: The redirect target is never another origin.
35
+ check: [npm, test, --, auth/redirect]
36
+ source: docs/security/redirects.md
37
+ non_goals:
38
+ - Changing how sessions expire.
39
+ ---
40
+
41
+ Notes and plan go here.
42
+ ```
43
+
44
+ | Field | Meaning |
45
+ | --- | --- |
46
+ | `id` | The file's name without `.md`: letters, digits, `.`, `_` or `-`. |
47
+ | `objective` | One outcome, in a sentence. |
48
+ | `scope` | Paths and globs the work may change. A plain path covers what is below it; `*` stays within a directory and `**` crosses them. `<sibling slug>:path` names a sibling's file. |
49
+ | `depends_on` | Orders that must be done before this one signs off. |
50
+ | `gates` | Declared `[testing]` gates `work check` runs besides the criteria's checks. |
51
+ | `criteria` | One to twelve, each an `id`, a `text` a reviewer cannot read two ways, an optional `check` and an optional `source`. |
52
+ | `check` | `{ gate, select }` runs a declared gate, optionally selected as `test-run --select` takes it; a list is an argv cockpit runs from the cockpit root, without a shell. |
53
+ | `source` | A file the criterion comes from. Its bytes are part of the contract, so a changed source makes the order stale. |
54
+ | `non_goals` | What the order deliberately leaves alone. |
55
+
56
+ One schema validates the front-matter; a refusal names each field it rejects. The digest an acceptance
57
+ binds covers the front-matter, the bytes of every `source`, and the commit each repository stood at
58
+ when the order was accepted. A check is the product's own test command, so it runs like a product test
59
+ stage: from the directory holding `cockpit.toml`, with `PATH` and the same non-secret variables,
60
+ judged by exit status, keeping the end of long output, for at most `work.verification_timeout_seconds`.
61
+
62
+ ## Accept, check, grade and close
63
+
64
+ <!-- cockpit:ref command=work status --> <!-- cockpit:ref command=work accept --> <!-- cockpit:ref command=work check --> <!-- cockpit:ref command=work grade --> <!-- cockpit:ref command=work signoff -->
65
+
66
+ ```sh
67
+ npx --no cockpit work accept --config cockpit.toml --work-order WO-1
68
+ npx --no cockpit work check --config cockpit.toml --work-order WO-1
69
+ npx --no cockpit work grade --config cockpit.toml --work-order WO-1
70
+ npx --no cockpit work signoff --config cockpit.toml --work-order WO-1
71
+ npx --no cockpit work status --config cockpit.toml
72
+ ```
73
+
74
+ An order moves draft → accepted → checked → graded → done. `work status` also shows three states it
75
+ derives: **stale** when the file or a source changed since acceptance, **blocked** while an open
76
+ question waits or the attempt cap is reached, and **draft** before acceptance. `partial` and
77
+ `abandoned` are closures. The work-in-progress cap, `work.wip_cap`, counts accepted orders that are
78
+ not closed, and `work accept` refuses a new one past it. A failed check or grade counts an attempt;
79
+ at `work.attempt_cap` the order is blocked until a person amends or closes it.
80
+
81
+ - **accept** records the contract. Run it again after editing an accepted order to accept the change
82
+ (an amendment). The check base stays the commits recorded at first acceptance.
83
+ - **check** runs every criterion's check and the order's gates, in the order the contract lists them.
84
+ - **grade** grades the order the way [`[work] grading`](#how-an-order-earns-done) says, and reports
85
+ every changed file outside `scope` since the base commits; a change outside scope fails the grade.
86
+ - **signoff** closes a graded order as done. `--partial --note <what is left>` or `--abandon --note <why>`
87
+ closes any open order without a passing grade.
88
+ - <!-- cockpit:ref command=work note --> <!-- cockpit:ref command=work answer --> **note** records a note; `--ask` records an open question, which blocks the order until
89
+ `work answer --question <n> --text <answer>` answers it.
90
+ - <!-- cockpit:ref command=work recover --> **recover** inspects the lock a crashed command left on an order; a person adds `--apply` once
91
+ the holder it names is gone, since a holder in a container or on another host looks dead from here.
92
+
93
+ <!-- cockpit:ref path=packages/cockpit/src/guards/evaluator.ts --> cockpit keeps what happened in `.cockpit/work-orders/<id>.json`, committed with the work. It
94
+ records who passed each step (`agent`, `person` or `ci`), each check's result, the grade with the
95
+ evaluator that answered and those passed over, notes, and how the order closed. Only cockpit writes
96
+ those records: the guard denies an agent's writes below `.cockpit/work-orders/` as
97
+ [guard controls](safety-and-autonomy.md#configuration-git-and-guard-controls), because a record carries
98
+ no key and one an agent wrote would read as a person's. That holds only for the tool calls the guard
99
+ inspects; a process outside the guard can still replace a record.
100
+
101
+ ## Who passes each gate
102
+
103
+ <!-- cockpit:ref schema=work.gates.accept --> `[work.gates]` in `cockpit.toml` says who passes each step: `accept`, `amend` (accepting a changed
104
+ contract), `answer` and `signoff`. The defaults are all `agent`.
105
+
106
+ | Value | What happens |
107
+ | --- | --- |
108
+ | `agent` | The command passes the step. |
109
+ | `person` | The command shows what is being decided and its digest, and records the step only after a person types that digest in a terminal with `--interactive`. Without a terminal it refuses with `WORK_PERSON_REQUIRED`. |
110
+ | `ci` | The command records the step as awaiting CI, and it counts once the required `work verify` job passes. |
111
+
112
+ Removing or rewriting an accepted criterion is never the agent's to certify: under `amend = "agent"`
113
+ such an amendment still needs a person.
114
+
115
+ These local gates are best-effort. The guard keeps an agent's tool call from passing `--interactive`,
116
+ and the command needs a terminal on both standard input and standard output, but cockpit cannot tell a
117
+ person from a program driving a terminal. The only enforced human gate is `ci`: a required
118
+ `work verify` job behind a protected environment whose reviewers must approve it before it runs.
119
+
120
+ <!-- cockpit:ref command=work verify --> `work verify` checks every record against its order: an open order whose contract changed since
121
+ acceptance fails, and so does a done order without a passing grade of its accepted contract. Run in
122
+ CI (where `CI` is set), it reports the steps gated on `ci` as confirmed; run locally it lists them as
123
+ awaiting CI. A workflow job for it:
124
+
125
+ ```yaml
126
+ work-verify:
127
+ runs-on: ubuntu-latest
128
+ environment: work-orders # add required reviewers to this environment
129
+ steps:
130
+ - uses: actions/checkout@v4
131
+ - uses: actions/setup-node@v4
132
+ with: { node-version: 24 }
133
+ - run: npm ci
134
+ - run: npx --no cockpit work verify --config cockpit.toml
135
+ ```
136
+
137
+ Make the job a required status check on the protected branch, so an order's `ci` steps reach the branch
138
+ only through it.
139
+
140
+ ## How an order earns done
141
+
142
+ <!-- cockpit:ref schema=work.grading --> `[work] grading` says what `work grade` does, and the record, `work status` and `doctor` always say
143
+ which way each order closed:
144
+
145
+ | `grading` | `work grade` | Closes as |
146
+ | --- | --- | --- |
147
+ | `independent` (default) | runs the checks, then an evaluator grades each criterion against them and the change | `graded` |
148
+ | `checks` | runs the checks; every criterion needs one, and the grade passes only when cockpit ran each and all passed | `checks-passed` |
149
+ | `self` | runs the checks, then records the agent's `--verdict met` or `unmet` with a `--note` saying what it checked; a failed check or a change outside `scope` still fails it | `self-reported` |
150
+
151
+ The signoff gate applies on top in every mode.
152
+
153
+ <!-- cockpit:ref command=grade --> For a change that needs no order, `cockpit grade --config cockpit.toml --criteria <file>` (a file inside the repository, relative or absolute) runs the
154
+ checks and the independent evaluator against the uncommitted change, and records nothing. The file has
155
+ the same front-matter `criteria` list, and optionally `scope`, `gates` and `objective`.
156
+
157
+ ## The independent evaluator
158
+
159
+ <!-- cockpit:ref schema=work.evaluators --> <!-- cockpit:ref schema=work.evaluator_timeout_seconds --> The grader runs headless in an agent tool from `work.evaluators`, whichever agent tool did the work,
160
+ so `independent` grading needs one of them on `PATH` and signed in. The list is tried in order (by
161
+ default the tools `surfaces.tools` lists that can evaluate, in that order, so a Claude Code-only cockpit
162
+ grades with Claude Code; when the command runs inside Claude Code or Codex, detected from `CLAUDECODE` or `CODEX_THREAD_ID` as measured 2026-11-03, that tool moves last so another listed tool grades first, and a list you write is used exactly as written; `--evaluator <tool>` on `work grade` or `grade --criteria` runs only that one tool for that run, with no fallback): a tool that is missing, too old for the flags below, signed out,
163
+ rate-limited or timed out is passed over and the next is tried, and the grade record names which tool
164
+ answered and which were passed over. When the tool that answered is the one running the command, the
165
+ command also prints a warning naming each tool passed over and why, because that is the weaker grade. An answer that fails cockpit's checks is refused rather than
166
+ retried with another tool, and nothing falls back to the agent grading its own work. Each grade is a
167
+ new run of its own, with no session saved to resume, so it never sees the session of the agent that did
168
+ the work; an answer from a session that already answered is refused:
169
+
170
+ | Tool | Runs | Read-only by | Install and sign in |
171
+ |---|---|---|---|
172
+ | `codexcli` | `codex exec --ephemeral --ignore-user-config` | `--sandbox read-only` | `npm install -g @openai/codex`, then `codex login` |
173
+ | `claudecode` | `claude --print --no-session-persistence --safe-mode --strict-mcp-config` | `--tools Read,Grep,Glob`, so no tool that writes or runs commands exists; `--permission-mode dontAsk` | `npm install -g @anthropic-ai/claude-code`, then `claude auth login` |
174
+ | `opencode` | `opencode run --pure` | `OPENCODE_PERMISSION` denying its edit, shell, task, web, skill, question, to-do and LSP tools, so they are not offered | `npm install -g opencode-ai`, then `opencode auth login` |
175
+
176
+ Each read-only mechanism was measured on 2026-11-03 beside a control without it: Codex 0.166.0 refused a shell write that `--sandbox workspace-write` made, Claude Code 2.1.317 wrote a file only once `Write` joined its tools, and OpenCode 1.19.5 offered only its read, glob, grep and list tools under the denials and offered write once edit was not denied. Codex and Claude Code skip your own configuration, hooks, plugins and MCP servers; OpenCode keeps your configuration, so it uses your model and provider, but skips external plugins, and a tool an MCP server in your OpenCode configuration adds is outside its denials. Claude Code and OpenCode constrain the answer with the output schema only as far as the tool does (OpenCode not at all), so cockpit checks every answer against that same schema and refuses one that does not match. Once a cockpit has a `workorders/` directory, `doctor` warns when no listed tool is on `PATH`. Each run starts in a private directory outside the repository, so the tool does not load the repository's `AGENTS.md`, `CLAUDE.md` or project configuration, which the agent being graded can write; the request names the repository. Each run may take `work.evaluator_timeout_seconds`. When no listed tool answers, the refusal names, for each, the cause it reported and the command that fixes it.
177
+
178
+ The grader is given each criterion with the result of the check cockpit ran for it, and every changed
179
+ file's diff since the base commits. A credential-shaped path or a declared environment instance is
180
+ listed with its content withheld. A `met` verdict stands only when each quote it cites, at least
181
+ eight characters long, is found exactly once in the change or check output it names; otherwise it
182
+ reads as `unclear` with the reason. No evaluator runs with a browser, and the request says so, so a
183
+ criterion about what a person would see is not met by command output alone.
184
+
185
+ <!-- cockpit:ref path=packages/cockpit/surfaces/agents/evaluator.agent.md --> The shipped evaluator instructions tell it to treat repository content, order text and command
186
+ output as data, never as instructions; that is instructed, not enforced. Run by `work grade` and
187
+ `cockpit grade`, the evaluator is also held read-only by its tool's mechanism above. Spawned as a
188
+ subagent inside an agent tool, that restriction is instructed, not enforced: no harness is confirmed to
189
+ enforce it there.
190
+
191
+ ## Browser evidence
192
+
193
+ <!-- cockpit:ref command=browser drive --> <!-- cockpit:ref command=browser screenshot --> `browser drive` and `browser screenshot` launch a local browser through a Playwright project of your own and visit the application's declared routes. They need the current runtime and the recorded application ports. Real sign-in additionally ties the session to the current namespace, stack and [holder](concepts.md#holder). With serialized sign-in, the shared state lock is held. With per-worker sign-in, each declared worker holds an exclusive claim across processes; different workers can run at the same time. A missing or stale claim is refused. Application origins come from the recorded application ports.
194
+
195
+ Declared application routes select the starting destinations for driving. Automated operations drive one page; secondary top-level page navigation is refused. HTTP requests and redirects use the browser's native networking, cookies and DNS. A record is made only after navigation ends successfully on a stable page on the current application origin. These checks validate what the browser saw; they do not sandbox outgoing network traffic. Requests to any loopback or `browser.resolver` host outside the application origin are refused, but Playwright does not route a request the browser follows as a redirect, so a redirect to another loopback port still reaches it.
196
+
197
+ <!-- cockpit:ref path=packages/cockpit/src/browser/commands.ts --> A screenshot record holds the path of the saved image, which must be inside the project, and its digest. Every distinct declared application route is covered.
198
+
199
+ When navigation fails, the refusal names the route's URL and the browser's own error code, never page text. `ERR_CONNECTION_REFUSED` or `ERR_SOCKS_CONNECTION_FAILED` means nothing answered at that URL, so check the stack with `doctor` before treating it as a product finding. A certificate error means the browser does not trust the issuer, and an HTTP status means the route answered but not with 2xx.
200
+
201
+ ### Declare a browser
202
+
203
+ <!-- cockpit:ref schema=browser --> A browser needs these pieces beside the `[browser]` table:
204
+
205
+ - **A browser project.** A `package.json` that depends on `@playwright/test` at exactly `browser.project.driver_version`, its npm `package-lock.json`, and both SHA-256 digests in `browser.project`. Keep it apart from the product's own manifest, and run `npm ci` in that directory on this machine: the browser commands load its Playwright module.
206
+ - **Entry points.** Exactly one `kind = "origin"` entry in `environment.entrypoint_derivatives`. Its route, and every other declared entry on the same origin, is a route the commands visit.
207
+ - **Artifact roots.** The three `browser.artifacts` paths, listed as `environment.generated_host_paths`. The screenshot command creates the `cli` root when it is missing.
208
+ - **A measured harness row.** The commands drive through a `browser.harnesses` row whose version cockpit has measured; the rendered skill reports other versions as unavailable.
209
+
210
+ cockpit does not run a browser service or a browser debugger. To let an agent drive a browser through its own Playwright MCP tools, run a browser service in your own Compose file and name its loopback address in `browser.endpoint`. cockpit then publishes the Playwright MCP declaration, which you approve by setting `playwright` under `[mcp.approved]` to the sha256 `sync` reports (its server must be declared in a JSON file with an `mcpServers` object, listed in a sibling's `derives.mcp_sources`), and the `cockpit-browser` skill. Without an endpoint, the Playwright MCP server and the skill are reported unavailable and not rendered; `browser drive` and `browser screenshot` are the working path. The published MCP points at the endpoint as declared, so it reaches the stack started on the base port and does not follow a leased port offset of a second parallel stack. The MCP transports are not measured in any tool; the [capability matrix](capability-matrix.md) says so.
211
+
212
+ ## Choose the proving test tier
213
+
214
+ <!-- cockpit:ref command=test-run --> <!-- cockpit:ref schema=testing.gates --> Product stages and gates are declared in configuration. Use the narrowest proving gate for isolated behavior, and the complete declared gate for shared behavior. Documentation-only changes need no behavioral gate when behavior is unchanged. `init` proposes stages and gates from the test entry points it finds. With no `[testing]` declared, the generated guide tells agents to add a characterization test in the product's own runner and never to report work as passing, and `doctor` warns that nothing can be graded PASS.
215
+
216
+ ### Choosing stage commands
217
+
218
+ <!-- cockpit:ref schema=testing.stages --> Let the product's build or test tool decide what a change affects; cockpit runs it and reports the verdict. Declare the tool's affected or changed-since run as a targeted gate and its complete run as a full gate. A stage command is an argv array run without a shell, from the directory holding `cockpit.toml`. Its environment is `PATH` plus `HOME`, `USER`, `LOGNAME`, `TMPDIR`, `LANG`, `LC_ALL`, `LC_CTYPE` and `TZ` when set; every other variable, including credentials, is withheld. A stage that outlives its `timeout_seconds` is stopped and the gate reports DEGRADED, naming the field to raise; the default and ceiling are in the [configuration reference](configuration-reference.md). Output past the capture limit is dropped from the front and the stage is still judged by its exit status.
219
+
220
+ <!-- cockpit:ref schema=testing.stages --> `test-run --select <kind>:<value>` narrows a gate, where kind is `spec`, `file`, `test` or `route`. A stage receives the selection through its own `select` table: for each kind it accepts, the arguments to append in its tool's spelling, with `{value}` replaced by the selected value. A stage that declares no arguments for the selected kind is not run and reports DEGRADED rather than running unselected:
221
+
222
+ ```toml
223
+ stages = [
224
+ { id = "unit", command = ["npx", "vitest", "run"], requires_stack = false, select = { file = ["{value}"], test = ["-t", "{value}"] } },
225
+ ]
226
+ ```
227
+
228
+ The same shape fits other runners: Jest takes `file = ["{value}"]` and `test = ["-t", "{value}"]`, pytest takes `file = ["{value}"]` and `test = ["-k", "{value}"]`, and `go test ./...` takes `test = ["-run", "{value}"]`.
229
+
230
+ This Nx example assumes the product checkout is at `../web` and its mainline is `origin/main`:
231
+
232
+ ```toml
233
+ [testing]
234
+ stages = [
235
+ { id = "affected", command = ["sh", "-c", "cd ../web && exec npx nx affected -t test --base=origin/main"], requires_stack = false },
236
+ { id = "all", command = ["sh", "-c", "cd ../web && exec npx nx run-many -t test"], requires_stack = false },
237
+ ]
238
+ gates = [
239
+ { id = "affected", tier = "targeted", stages = ["affected"], changes = "Isolated behavior; Nx selects the affected projects" },
240
+ { id = "all", tier = "full", stages = ["all"], changes = "Shared or cross-project behavior" },
241
+ ]
242
+ ```
243
+
244
+ Nx compares the base with the current working tree, so uncommitted edits count. For other tools, keep the stage shape and change the command:
245
+
246
+ - **Bazel:** `bazel test //...` for both gates. With the default `--cache_test_results=auto`, Bazel reports cached results for tests whose inputs did not change and passed before, so the full run is already change-aware.
247
+ - **Turborepo:** `npx turbo run test --affected` for the targeted stage and `npx turbo run test` for the full one. Turborepo documents `--affected` as `--filter=...[main...HEAD]`, a comparison of commits, so commit work in progress before relying on it.
248
+ - **A runner's changed-file mode:** `npx vitest run --changed origin/main` or `npx jest --changedSince=origin/main` for the targeted stage, and the plain run for the full one.
249
+
250
+ <!-- cockpit:ref path=packages/cockpit/src/testing/verdict.ts --> Only a complete, stable declared gate whose checks all pass can report PASS. If a check ran and failed, the result is FAIL. If results are unavailable or malformed, it is DEGRADED. Otherwise, if the listed files changed or are stale, it is VOID. Otherwise, a subset or staged run is PARTIAL. A target selector passes your selection to the product's own tool and never substitutes a made-up pass. Every verdict returns the same result, with each stage's observation; any verdict but PASS also exits non-zero with `TEST_GATE_UNSATISFIED`.
251
+
252
+ <!-- cockpit:ref schema=testing.native_hooks.install --> Native hooks are inspect-only by default. Writing native hook output needs a policy tied to cockpit's ownership record and a measurement of its cost. Rendering a hook and describing how to activate it does not switch on the Git hook path. A person must install it and check separately that it works. Leave `native_hooks` out to keep that default; declare `native_hooks = { install = "explicit-native" }` only together with `native_hook_policy`.
253
+
254
+ See the generated [CLI reference](cli-reference.md), [configuration reference](configuration-reference.md), [capability matrix](capability-matrix.md) and [diagnostic index](troubleshooting.md) for the current declared surfaces and how far each is confirmed.
@@ -0,0 +1,186 @@
1
+ 'use strict'
2
+
3
+ // Drives one browser operation for packages/cockpit/src/browser/surfaces.ts through the project's own
4
+ // Playwright module: navigate to a surface, then report it or save a screenshot into the artifact
5
+ // root. Runs from a staged snapshot, so it must stay self-contained.
6
+ //
7
+ // argv: named flags (--operation, --engine, --connection, --url, --origin, --timeout, --module,
8
+ // --module-proof, --control-authorities, --control-ports, --screenshot-limit, and the screenshot
9
+ // artifact flags); see operationStep in surfaces.ts for the full set.
10
+ // stdout on success: one JSON 'cockpit-browser-observation/v1' object.
11
+ // stderr on failure: one JSON 'cockpit-browser-failure/v1' object naming the phase and its category,
12
+ // and, when one is known, a fixed-vocabulary reason (a browser network error code such as
13
+ // ERR_CONNECTION_REFUSED, HTTP_<status> or TIMEOUT), exit code 1. The failure deliberately carries no
14
+ // error text: page content, URLs and storage state must not leak into cockpit's output.
15
+ //
16
+ // Requests to cockpit's own browser-control authorities and nested top-level navigations are blocked
17
+ // so the page under test cannot reach the control plane that drives it. Requests to a loopback or
18
+ // resolver-mapped host outside the application origin are blocked too: such an address is the host
19
+ // (through the remote runner's network exposure, or directly for a local browser), and the operation
20
+ // was given only the application. Playwright does not route a request the browser follows as a
21
+ // redirect, so a redirect to another loopback port still reaches it; this blocks direct requests only.
22
+ const fs = require('node:fs')
23
+ const crypto = require('node:crypto')
24
+ const path = require('node:path')
25
+ const args = process.argv.slice(2)
26
+ const one = name => { const index = args.indexOf(name); if (index < 0 || index + 1 >= args.length) throw new Error('missing argument'); return args[index + 1] }
27
+ const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex')
28
+ async function main() {
29
+ const moduleProof = fs.readFileSync(one('--module-proof'))
30
+ const modulePath = one('--module')
31
+ const moduleBytes = fs.readFileSync(modulePath)
32
+ if (digest(moduleProof) !== digest(moduleBytes)) throw new Error('module changed')
33
+ const api = require(modulePath)
34
+ const engine = one('--engine')
35
+ const browserType = api && api[engine]
36
+ const connection = one('--connection')
37
+ if (!['chromium', 'firefox', 'webkit'].includes(engine) || !browserType
38
+ || connection === 'remote' && typeof browserType.connect !== 'function'
39
+ || connection === 'local' && typeof browserType.launch !== 'function') throw new Error('browser module lacks selected engine operation')
40
+ const operation = one('--operation')
41
+ const url = one('--url')
42
+ const timeout = Number(one('--timeout'))
43
+ const deadline = Date.now() + timeout
44
+ const remaining = () => { const value = deadline - Date.now(); if (value <= 0) throw new Error('browser operation deadline expired'); return value }
45
+ const resolverRule = args.includes('--resolver-rule') ? one('--resolver-rule') : undefined
46
+ const categories = { connect: 'availability', context: 'setup', navigation: 'navigation',
47
+ screenshot: 'observation', artifact: 'artifact', cleanup: 'cleanup' }
48
+ let browser, context, page, observation, failurePhase, failureReason, httpStatus, cleanupFailed = false, phase = 'connect'
49
+ try {
50
+ browser = connection === 'remote'
51
+ ? await browserType.connect(one('--endpoint'), { timeout: remaining(), exposeNetwork: one('--expose-network') })
52
+ : await browserType.launch({ headless: true, timeout: remaining(),
53
+ ...(resolverRule ? { args: ['--host-resolver-rules=' + resolverRule] } : {}) })
54
+ phase = 'context'
55
+ const state = process.env.COCKPIT_BROWSER_STORAGE_STATE
56
+ const stateProof = state ? fs.readFileSync(one('--state-proof')) : undefined
57
+ if (state && digest(fs.readFileSync(state)) !== digest(stateProof)) throw new Error('storage state changed')
58
+ const storageState = stateProof ? JSON.parse(stateProof.toString('utf8')) : undefined
59
+ context = await browser.newContext({ ...(storageState ? { storageState } : {}), serviceWorkers: 'block' })
60
+ const controlAuthorities = new Set(JSON.parse(one('--control-authorities')))
61
+ const controlPorts = new Set(JSON.parse(one('--control-ports')).map(String))
62
+ // An address that does not parse cannot be shown to be safe, so both guards treat it as blocked
63
+ // rather than naming the error, which would quote the address.
64
+ const isControlAuthority = value => { try {
65
+ const parsed = new URL(value)
66
+ const port = parsed.port || (parsed.protocol === 'http:' || parsed.protocol === 'ws:' ? '80'
67
+ : parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? '443' : '')
68
+ return controlAuthorities.has(parsed.hostname + ':' + port) || controlPorts.has(port)
69
+ } catch { return true } }
70
+ const origin = one('--origin')
71
+ const mappedHosts = new Set((resolverRule || '').split(',').map(rule => rule.trim().split(/\s+/))
72
+ .filter(parts => parts[0] === 'MAP' && parts[1]).map(parts => parts[1].toLowerCase()))
73
+ const isForeignHost = value => { try {
74
+ const parsed = new URL(value)
75
+ const scheme = { 'ws:': 'http:', 'wss:': 'https:' }[parsed.protocol] || parsed.protocol
76
+ if (!['http:', 'https:'].includes(scheme) || new URL(scheme + '//' + parsed.host).origin === origin) return false
77
+ const hostname = parsed.hostname
78
+ return hostname === 'localhost' || hostname.endsWith('.localhost') || /^127\./.test(hostname) || hostname === '0.0.0.0'
79
+ || hostname === '[::1]' || hostname.startsWith('[::ffff:7f') || mappedHosts.has(hostname)
80
+ } catch { return true } }
81
+ let mainNavigationAttempts = 0
82
+ await context.route('**/*', async (route, request) => {
83
+ if (isControlAuthority(request.url()) || isForeignHost(request.url())) return route.abort('blockedbyclient')
84
+ const frame = request.frame()
85
+ const mainNavigation = request.isNavigationRequest() && page && frame === page.mainFrame()
86
+ if (request.isNavigationRequest() && !mainNavigation
87
+ && (!frame || typeof frame.parentFrame !== 'function' || frame.parentFrame() === null)) return route.abort('blockedbyclient')
88
+ if (mainNavigation) mainNavigationAttempts += 1
89
+ await route.continue()
90
+ })
91
+ if (typeof context.routeWebSocket !== 'function') throw new Error('browser module lacks WebSocket routing')
92
+ await context.routeWebSocket(() => true, webSocket => {
93
+ if (isControlAuthority(webSocket.url()) || isForeignHost(webSocket.url())) return webSocket.close({ code: 1008, reason: 'blocked by the cockpit browser operation' })
94
+ webSocket.connectToServer()
95
+ })
96
+ phase = 'navigation'
97
+ page = await context.newPage()
98
+ let documentEpoch = 0
99
+ let documentResponse
100
+ page.on('framenavigated', frame => { if (frame === page.mainFrame()) documentEpoch += 1 })
101
+ page.on('response', response => {
102
+ const request = response.request()
103
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame()) documentResponse = response
104
+ })
105
+ const currentDocument = () => {
106
+ const displayed = new URL(page.url())
107
+ if (displayed.origin !== origin || displayed.username || displayed.password || displayed.hash) throw new Error('navigation left application origin')
108
+ if (!documentResponse || typeof documentResponse.ok !== 'function' || !documentResponse.ok()
109
+ || typeof documentResponse.url !== 'function' || documentResponse.url() !== displayed.href) {
110
+ throw new Error('displayed document lacks a successful terminal HTTP response')
111
+ }
112
+ return { url: displayed.href, epoch: documentEpoch, attempts: mainNavigationAttempts }
113
+ }
114
+ const response = await page.goto(url, { timeout: remaining() })
115
+ if (response && typeof response.status === 'function') httpStatus = response.status()
116
+ if (!response || typeof response.ok !== 'function' || !response.ok()) throw new Error('navigation did not return a successful HTTP response')
117
+ documentResponse = response
118
+ const initialDocument = currentDocument()
119
+ if (operation === 'drive') observation = { schema: 'cockpit-browser-observation/v1', operation, finalUrl: initialDocument.url }
120
+ else if (operation === 'screenshot') {
121
+ phase = 'screenshot'
122
+ const displayedBefore = currentDocument()
123
+ const bytes = await page.screenshot()
124
+ if (!Buffer.isBuffer(bytes) || bytes.byteLength > Number(one('--screenshot-limit'))) throw new Error('screenshot observation exceeds bound')
125
+ const displayedAfter = currentDocument()
126
+ if (displayedAfter.url !== displayedBefore.url || displayedAfter.epoch !== displayedBefore.epoch
127
+ || displayedAfter.attempts !== displayedBefore.attempts) throw new Error('navigation changed during screenshot observation')
128
+ const output = one('--output')
129
+ const root = one('--artifact-root')
130
+ const expectedDev = Number(one('--artifact-dev'))
131
+ const expectedIno = Number(one('--artifact-ino'))
132
+ const expectedBirthtimeNs = one('--artifact-birthtime-ns')
133
+ phase = 'artifact'
134
+ if (path.dirname(output) !== root || path.basename(output) !== one('--output-name')) throw new Error('screenshot output escaped root')
135
+ let rootDescriptor, dotDescriptor, descriptor
136
+ try {
137
+ rootDescriptor = fs.openSync(root, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
138
+ const held = fs.fstatSync(rootDescriptor)
139
+ const named = fs.lstatSync(root)
140
+ // ext4 reuses a freed inode for the next directory created, so a root recreated since
141
+ // planning can carry the planned device and inode; birth time tells the two apart.
142
+ if (!held.isDirectory() || named.isSymbolicLink() || held.dev !== expectedDev || held.ino !== expectedIno
143
+ || String(fs.fstatSync(rootDescriptor, { bigint: true }).birthtimeNs) !== expectedBirthtimeNs
144
+ || named.dev !== held.dev || named.ino !== held.ino) throw new Error('artifact root changed')
145
+ process.chdir(root)
146
+ dotDescriptor = fs.openSync('.', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
147
+ const anchored = fs.fstatSync(dotDescriptor)
148
+ if (anchored.dev !== held.dev || anchored.ino !== held.ino) throw new Error('artifact root changed')
149
+ descriptor = fs.openSync(one('--output-name'), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
150
+ fs.fchmodSync(descriptor, 0o600); fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor)
151
+ const after = fs.fstatSync(dotDescriptor)
152
+ if (after.dev !== held.dev || after.ino !== held.ino) throw new Error('artifact root changed')
153
+ } finally {
154
+ if (descriptor !== undefined) fs.closeSync(descriptor)
155
+ if (dotDescriptor !== undefined) fs.closeSync(dotDescriptor)
156
+ if (rootDescriptor !== undefined) fs.closeSync(rootDescriptor)
157
+ }
158
+ observation = { schema: 'cockpit-browser-observation/v1', operation, finalUrl: displayedAfter.url, evidence: { sha256: digest(bytes), byteLength: bytes.byteLength } }
159
+ } else throw new Error('unsupported operation')
160
+ } catch (error) {
161
+ failurePhase = phase
162
+ // Only identifiers leave this process: Chromium's net error code, the HTTP status or a timeout.
163
+ const message = error && typeof error.message === 'string' ? error.message : ''
164
+ const network = /net::(ERR_[A-Z0-9_]{1,64})\b/.exec(message)
165
+ failureReason = network ? network[1]
166
+ : error && error.name === 'TimeoutError' ? 'TIMEOUT'
167
+ : phase === 'navigation' && Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599
168
+ && (httpStatus < 200 || httpStatus > 299) ? 'HTTP_' + httpStatus
169
+ : undefined
170
+ }
171
+ finally {
172
+ for (const value of [page, context, browser]) if (value && typeof value.close === 'function') {
173
+ // The failure record carries a cleanup flag and never a message, so the error is not kept.
174
+ try { await value.close() } catch { cleanupFailed = true }
175
+ }
176
+ }
177
+ if (failurePhase || cleanupFailed || !observation) {
178
+ const failedAt = failurePhase || 'cleanup'
179
+ process.stderr.write(JSON.stringify({ schema: 'cockpit-browser-failure/v1', phase: failedAt,
180
+ category: categories[failedAt], cleanup: cleanupFailed, ...(failurePhase && failureReason ? { reason: failureReason } : {}) }))
181
+ process.exitCode = 1
182
+ return
183
+ }
184
+ process.stdout.write(JSON.stringify(observation))
185
+ }
186
+ main().catch(() => { process.stderr.write('{"schema":"cockpit-browser-failure/v1","phase":"context","category":"setup","cleanup":false}'); process.exitCode = 1 })
@@ -0,0 +1,101 @@
1
+ 'use strict'
2
+
3
+ // Reads and replaces the executor's fence marker: the small JSON file that records whether a fenced
4
+ // attempt is active, terminating, terminated or cancelled. The executor
5
+ // (packages/cockpit/src/environment-exec/execute.ts), libexec/fenced-supervisor.cjs and
6
+ // libexec/fenced-group-runner.cjs all read it here so they agree on what an authentic marker is.
7
+ //
8
+ // A marker is accepted only when the bytes came from the file that is named now: it is opened without
9
+ // following links, read whole within a small bound, and the descriptor, the named entry and the parent
10
+ // directory must still agree afterwards. Each marker records its own device and inode, so a copy or a
11
+ // marker renamed into place from elsewhere does not match the file it claims to be. This module
12
+ // decides nothing about status; callers check the fields they need.
13
+ const fs = require('node:fs')
14
+ const path = require('node:path')
15
+
16
+ const FENCE_MARKER_MAX_BYTES = 1024n
17
+
18
+ function readFenceMarkerFile(markerPath) {
19
+ let fd
20
+ try {
21
+ fd = fs.openSync(markerPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
22
+ const before = fs.fstatSync(fd, { bigint: true })
23
+ if (!before.isFile() || before.size > FENCE_MARKER_MAX_BYTES) return undefined
24
+ const bytes = Buffer.alloc(Number(before.size) + 1)
25
+ const count = fs.readSync(fd, bytes, 0, bytes.length, 0)
26
+ const after = fs.fstatSync(fd, { bigint: true })
27
+ const named = fs.lstatSync(markerPath, { bigint: true })
28
+ const parent = fs.lstatSync(path.dirname(markerPath), { bigint: true })
29
+ if (count !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
30
+ || !named.isFile() || named.dev !== before.dev || named.ino !== before.ino
31
+ || !parent.isDirectory() || parent.isSymbolicLink()) return undefined
32
+ return {
33
+ value: JSON.parse(bytes.subarray(0, count).toString('utf8')),
34
+ identity: {
35
+ markerDev: String(before.dev), markerIno: String(before.ino),
36
+ parentDev: String(parent.dev), parentIno: String(parent.ino),
37
+ },
38
+ }
39
+ } catch {
40
+ // Absent, unreadable and malformed markers all mean "no authentic marker"; every caller refuses
41
+ // on that answer, and the fence protocol has no channel that could carry the cause further.
42
+ return undefined
43
+ } finally {
44
+ if (fd !== undefined) try { fs.closeSync(fd) } catch { /* the answer above does not depend on the descriptor */ }
45
+ }
46
+ }
47
+
48
+ // The marker the executor published for this supervisor, unchanged since the executor named it in
49
+ // its release line. `approved` carries supervisorPid plus the four identity fields from that line.
50
+ function readApprovedActiveMarker(fence, approved) {
51
+ const read = readFenceMarkerFile(fence.markerPath)
52
+ if (!read) return undefined
53
+ const { value: marker, identity } = read
54
+ if (marker === null || typeof marker !== 'object'
55
+ || marker.format !== 'cockpit-executor/v1' || marker.attemptId !== fence.attemptId
56
+ || marker.supervisorPid !== approved.supervisorPid || marker.status !== 'active'
57
+ || marker.markerDev !== approved.markerDev || marker.markerIno !== approved.markerIno
58
+ || marker.parentDev !== approved.parentDev || marker.parentIno !== approved.parentIno
59
+ || marker.markerDev !== identity.markerDev || marker.markerIno !== identity.markerIno
60
+ || marker.parentDev !== identity.parentDev || marker.parentIno !== identity.parentIno) return undefined
61
+ return marker
62
+ }
63
+
64
+ function syncDirectory(directory) {
65
+ const fd = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
66
+ try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
67
+ }
68
+
69
+ // Replaces the approved active marker with one carrying `fields`, durably. The replacement is written
70
+ // beside the marker under a name owned by `writer` and this process, and renamed over the marker only
71
+ // if the active marker is still the approved one, so a concurrent repair or cancel is never overwritten.
72
+ function replaceApprovedActiveMarker(fence, approved, writer, fields) {
73
+ const current = readApprovedActiveMarker(fence, approved)
74
+ if (!current) return false
75
+ const temporary = `${fence.markerPath}.${writer}-${process.pid}`
76
+ let fd
77
+ try {
78
+ fd = fs.openSync(temporary, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600)
79
+ const identity = fs.fstatSync(fd, { bigint: true })
80
+ fs.writeFileSync(fd, `${JSON.stringify({ ...current, markerDev: String(identity.dev), markerIno: String(identity.ino), ...fields })}\n`)
81
+ fs.fsyncSync(fd)
82
+ fs.closeSync(fd)
83
+ fd = undefined
84
+ if (!readApprovedActiveMarker(fence, approved)) return false
85
+ fs.renameSync(temporary, fence.markerPath)
86
+ syncDirectory(path.dirname(fence.markerPath))
87
+ return true
88
+ } catch {
89
+ // Before the rename, nothing landed: the active marker stays in place, which repair reads as
90
+ // "attempt may still run". If only the directory sync failed, the rename has happened and the
91
+ // replacement is what a reader now sees, but a crash may still bring the old active marker
92
+ // back. Either way the new status is not known durable, so the caller gets false and must not
93
+ // report it published; repair then reads whichever marker survives and both are safe to act on.
94
+ return false
95
+ } finally {
96
+ if (fd !== undefined) try { fs.closeSync(fd) } catch { /* the replacement already failed */ }
97
+ try { fs.unlinkSync(temporary) } catch { /* renamed into place, or never created */ }
98
+ }
99
+ }
100
+
101
+ module.exports = { readFenceMarkerFile, replaceApprovedActiveMarker, syncDirectory }
@@ -0,0 +1,24 @@
1
+ export interface FenceMarkerIdentity {
2
+ readonly markerDev: string
3
+ readonly markerIno: string
4
+ readonly parentDev: string
5
+ readonly parentIno: string
6
+ }
7
+
8
+ export interface FenceMarkerLocation {
9
+ readonly attemptId: string
10
+ readonly markerPath: string
11
+ }
12
+
13
+ export interface ApprovedFenceMarker extends FenceMarkerIdentity {
14
+ readonly supervisorPid: number
15
+ }
16
+
17
+ export function readFenceMarkerFile(markerPath: string): { readonly value: unknown; readonly identity: FenceMarkerIdentity } | undefined
18
+ export function replaceApprovedActiveMarker(
19
+ fence: FenceMarkerLocation,
20
+ approved: ApprovedFenceMarker,
21
+ writer: string,
22
+ fields: Readonly<Record<string, unknown>>,
23
+ ): boolean
24
+ export function syncDirectory(directory: string): void