@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.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +21 -1
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +76 -1
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- 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
|