dorfl 0.14.2 → 0.15.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/dist/advance-ci-template.d.ts +17 -16
- package/dist/advance-ci-template.d.ts.map +1 -1
- package/dist/advance-ci-template.js +42 -53
- package/dist/advance-ci-template.js.map +1 -1
- package/dist/advance-drivers.d.ts +8 -0
- package/dist/advance-drivers.d.ts.map +1 -1
- package/dist/advance-drivers.js +28 -4
- package/dist/advance-drivers.js.map +1 -1
- package/dist/advance-isolated.d.ts.map +1 -1
- package/dist/advance-isolated.js +3 -2
- package/dist/advance-isolated.js.map +1 -1
- package/dist/advance-lifecycle-template.d.ts +41 -40
- package/dist/advance-lifecycle-template.d.ts.map +1 -1
- package/dist/advance-lifecycle-template.js +309 -489
- package/dist/advance-lifecycle-template.js.map +1 -1
- package/dist/advance-loop-driver.d.ts.map +1 -1
- package/dist/advance-loop-driver.js +3 -2
- package/dist/advance-loop-driver.js.map +1 -1
- package/dist/advance-treeless-publish.d.ts +31 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -1
- package/dist/advance-treeless-publish.js +40 -0
- package/dist/advance-treeless-publish.js.map +1 -1
- package/dist/advance.d.ts +63 -3
- package/dist/advance.d.ts.map +1 -1
- package/dist/advance.js +80 -12
- package/dist/advance.js.map +1 -1
- package/dist/advancing-lock.d.ts +9 -0
- package/dist/advancing-lock.d.ts.map +1 -1
- package/dist/advancing-lock.js +21 -1
- package/dist/advancing-lock.js.map +1 -1
- package/dist/agent-env.d.ts +61 -0
- package/dist/agent-env.d.ts.map +1 -0
- package/dist/agent-env.js +144 -0
- package/dist/agent-env.js.map +1 -0
- package/dist/agent-stop.d.ts +35 -0
- package/dist/agent-stop.d.ts.map +1 -1
- package/dist/agent-stop.js +62 -0
- package/dist/agent-stop.js.map +1 -1
- package/dist/apply-merge-action.d.ts +76 -34
- package/dist/apply-merge-action.d.ts.map +1 -1
- package/dist/apply-merge-action.js +339 -129
- package/dist/apply-merge-action.js.map +1 -1
- package/dist/apply-stuck-action.js +2 -2
- package/dist/apply-stuck-action.js.map +1 -1
- package/dist/ci-agent-result.d.ts +151 -0
- package/dist/ci-agent-result.d.ts.map +1 -0
- package/dist/ci-agent-result.js +314 -0
- package/dist/ci-agent-result.js.map +1 -0
- package/dist/ci-handoff-apply.d.ts +162 -0
- package/dist/ci-handoff-apply.d.ts.map +1 -0
- package/dist/ci-handoff-apply.js +497 -0
- package/dist/ci-handoff-apply.js.map +1 -0
- package/dist/ci-handoff-format.d.ts +369 -0
- package/dist/ci-handoff-format.d.ts.map +1 -0
- package/dist/ci-handoff-format.js +646 -0
- package/dist/ci-handoff-format.js.map +1 -0
- package/dist/ci-handoff-lfs.d.ts +146 -0
- package/dist/ci-handoff-lfs.d.ts.map +1 -0
- package/dist/ci-handoff-lfs.js +397 -0
- package/dist/ci-handoff-lfs.js.map +1 -0
- package/dist/ci-handoff.d.ts +99 -0
- package/dist/ci-handoff.d.ts.map +1 -0
- package/dist/ci-handoff.js +277 -0
- package/dist/ci-handoff.js.map +1 -0
- package/dist/ci-lock-outputs.d.ts +93 -0
- package/dist/ci-lock-outputs.d.ts.map +1 -0
- package/dist/ci-lock-outputs.js +223 -0
- package/dist/ci-lock-outputs.js.map +1 -0
- package/dist/ci-phase-build.d.ts +178 -0
- package/dist/ci-phase-build.d.ts.map +1 -0
- package/dist/ci-phase-build.js +989 -0
- package/dist/ci-phase-build.js.map +1 -0
- package/dist/ci-phase-driver.d.ts +247 -0
- package/dist/ci-phase-driver.d.ts.map +1 -0
- package/dist/ci-phase-driver.js +370 -0
- package/dist/ci-phase-driver.js.map +1 -0
- package/dist/ci-phase-intake.d.ts +172 -0
- package/dist/ci-phase-intake.d.ts.map +1 -0
- package/dist/ci-phase-intake.js +555 -0
- package/dist/ci-phase-intake.js.map +1 -0
- package/dist/ci-phase-tasking.d.ts +205 -0
- package/dist/ci-phase-tasking.d.ts.map +1 -0
- package/dist/ci-phase-tasking.js +906 -0
- package/dist/ci-phase-tasking.js.map +1 -0
- package/dist/ci-phase-treeless.d.ts +224 -0
- package/dist/ci-phase-treeless.d.ts.map +1 -0
- package/dist/ci-phase-treeless.js +1482 -0
- package/dist/ci-phase-treeless.js.map +1 -0
- package/dist/ci-read-token.d.ts +47 -0
- package/dist/ci-read-token.d.ts.map +1 -0
- package/dist/ci-read-token.js +80 -0
- package/dist/ci-read-token.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +447 -22
- package/dist/cli.js.map +1 -1
- package/dist/close-job-template.d.ts +10 -1
- package/dist/close-job-template.d.ts.map +1 -1
- package/dist/close-job-template.js +46 -14
- package/dist/close-job-template.js.map +1 -1
- package/dist/close-job.d.ts +20 -5
- package/dist/close-job.d.ts.map +1 -1
- package/dist/close-job.js +65 -12
- package/dist/close-job.js.map +1 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +20 -1
- package/dist/complete.js.map +1 -1
- package/dist/config.d.ts +41 -44
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -4
- package/dist/config.js.map +1 -1
- package/dist/continue-branch.d.ts +63 -15
- package/dist/continue-branch.d.ts.map +1 -1
- package/dist/continue-branch.js +75 -8
- package/dist/continue-branch.js.map +1 -1
- package/dist/crashed-run-save.d.ts +73 -0
- package/dist/crashed-run-save.d.ts.map +1 -0
- package/dist/crashed-run-save.js +86 -0
- package/dist/crashed-run-save.js.map +1 -0
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +10 -2
- package/dist/cwd-section.js.map +1 -1
- package/dist/decision-engine.d.ts +10 -0
- package/dist/decision-engine.d.ts.map +1 -1
- package/dist/decision-engine.js.map +1 -1
- package/dist/do-config.d.ts +2 -24
- package/dist/do-config.d.ts.map +1 -1
- package/dist/do-config.js +1 -33
- package/dist/do-config.js.map +1 -1
- package/dist/do.d.ts +149 -1
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +115 -25
- package/dist/do.js.map +1 -1
- package/dist/dorfl-item-template.d.ts +98 -0
- package/dist/dorfl-item-template.d.ts.map +1 -0
- package/dist/dorfl-item-template.js +504 -0
- package/dist/dorfl-item-template.js.map +1 -0
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +7 -5
- package/dist/env-config.js.map +1 -1
- package/dist/frontmatter.d.ts +52 -0
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +136 -2
- package/dist/frontmatter.js.map +1 -1
- package/dist/gc.d.ts +13 -0
- package/dist/gc.d.ts.map +1 -1
- package/dist/gc.js +16 -2
- package/dist/gc.js.map +1 -1
- package/dist/git.d.ts +18 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +70 -5
- package/dist/git.js.map +1 -1
- package/dist/harness.d.ts +44 -5
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +24 -1
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/install-ci-action-pins.d.ts +123 -0
- package/dist/install-ci-action-pins.d.ts.map +1 -0
- package/dist/install-ci-action-pins.js +190 -0
- package/dist/install-ci-action-pins.js.map +1 -0
- package/dist/install-ci-actions-pr-creation.d.ts +70 -0
- package/dist/install-ci-actions-pr-creation.d.ts.map +1 -0
- package/dist/install-ci-actions-pr-creation.js +106 -0
- package/dist/install-ci-actions-pr-creation.js.map +1 -0
- package/dist/install-ci-capabilities/dorfl-item.d.ts +12 -0
- package/dist/install-ci-capabilities/dorfl-item.d.ts.map +1 -0
- package/dist/install-ci-capabilities/dorfl-item.js +26 -0
- package/dist/install-ci-capabilities/dorfl-item.js.map +1 -0
- package/dist/install-ci-core.d.ts +155 -37
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +304 -200
- package/dist/install-ci-core.js.map +1 -1
- package/dist/install-ci-github.d.ts +47 -2
- package/dist/install-ci-github.d.ts.map +1 -1
- package/dist/install-ci-github.js +113 -1
- package/dist/install-ci-github.js.map +1 -1
- package/dist/install-ci.d.ts +25 -6
- package/dist/install-ci.d.ts.map +1 -1
- package/dist/install-ci.js +72 -36
- package/dist/install-ci.js.map +1 -1
- package/dist/intake-marker.d.ts +6 -1
- package/dist/intake-marker.d.ts.map +1 -1
- package/dist/intake-marker.js.map +1 -1
- package/dist/intake-triage.d.ts.map +1 -1
- package/dist/intake-triage.js +6 -2
- package/dist/intake-triage.js.map +1 -1
- package/dist/intake-trigger-template.d.ts +31 -25
- package/dist/intake-trigger-template.d.ts.map +1 -1
- package/dist/intake-trigger-template.js +107 -292
- package/dist/intake-trigger-template.js.map +1 -1
- package/dist/intake.d.ts +186 -4
- package/dist/intake.d.ts.map +1 -1
- package/dist/intake.js +533 -416
- package/dist/intake.js.map +1 -1
- package/dist/integration-core.d.ts +154 -1
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +759 -486
- package/dist/integration-core.js.map +1 -1
- package/dist/integrator.d.ts +12 -0
- package/dist/integrator.d.ts.map +1 -1
- package/dist/integrator.js +21 -0
- package/dist/integrator.js.map +1 -1
- package/dist/isolation.d.ts +12 -2
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +19 -8
- package/dist/isolation.js.map +1 -1
- package/dist/item-lock.d.ts +93 -11
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +233 -65
- package/dist/item-lock.js.map +1 -1
- package/dist/land-question-residue.d.ts +58 -0
- package/dist/land-question-residue.d.ts.map +1 -0
- package/dist/land-question-residue.js +95 -0
- package/dist/land-question-residue.js.map +1 -0
- package/dist/lifecycle-gather.d.ts +16 -0
- package/dist/lifecycle-gather.d.ts.map +1 -1
- package/dist/lifecycle-gather.js +54 -6
- package/dist/lifecycle-gather.js.map +1 -1
- package/dist/merge-answer.d.ts +41 -0
- package/dist/merge-answer.d.ts.map +1 -0
- package/dist/merge-answer.js +60 -0
- package/dist/merge-answer.js.map +1 -0
- package/dist/merge-question-surfacer.d.ts +105 -30
- package/dist/merge-question-surfacer.d.ts.map +1 -1
- package/dist/merge-question-surfacer.js +202 -91
- package/dist/merge-question-surfacer.js.map +1 -1
- package/dist/merge-question-tick.d.ts +64 -0
- package/dist/merge-question-tick.d.ts.map +1 -0
- package/dist/merge-question-tick.js +96 -0
- package/dist/merge-question-tick.js.map +1 -0
- package/dist/mint-adr.d.ts.map +1 -1
- package/dist/mint-adr.js +20 -2
- package/dist/mint-adr.js.map +1 -1
- package/dist/needs-attention.d.ts +72 -2
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +275 -38
- package/dist/needs-attention.js.map +1 -1
- package/dist/phase-recorder.d.ts +168 -0
- package/dist/phase-recorder.d.ts.map +1 -0
- package/dist/phase-recorder.js +218 -0
- package/dist/phase-recorder.js.map +1 -0
- package/dist/phase.d.ts +94 -0
- package/dist/phase.d.ts.map +1 -0
- package/dist/phase.js +148 -0
- package/dist/phase.js.map +1 -0
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +19 -6
- package/dist/pi-harness.js.map +1 -1
- package/dist/prepare.d.ts.map +1 -1
- package/dist/prepare.js +4 -0
- package/dist/prepare.js.map +1 -1
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +48 -1
- package/dist/prompt.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +3 -1
- package/dist/protocol/WORK-CONTRACT.md +1 -1
- package/dist/reap-branches.d.ts +48 -0
- package/dist/reap-branches.d.ts.map +1 -1
- package/dist/reap-branches.js +64 -0
- package/dist/reap-branches.js.map +1 -1
- package/dist/ref-write.d.ts +198 -0
- package/dist/ref-write.d.ts.map +1 -0
- package/dist/ref-write.js +79 -0
- package/dist/ref-write.js.map +1 -0
- package/dist/repo-config.js +8 -8
- package/dist/repo-config.js.map +1 -1
- package/dist/repo-mirror.d.ts +36 -0
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +91 -1
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-verdict.d.ts +1 -1
- package/dist/review-verdict.js +1 -1
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +28 -4
- package/dist/run.js.map +1 -1
- package/dist/scan.d.ts +19 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +21 -8
- package/dist/scan.js.map +1 -1
- package/dist/sidecar.d.ts +14 -0
- package/dist/sidecar.d.ts.map +1 -1
- package/dist/sidecar.js +21 -0
- package/dist/sidecar.js.map +1 -1
- package/dist/single-job-warning.d.ts +63 -0
- package/dist/single-job-warning.d.ts.map +1 -0
- package/dist/single-job-warning.js +110 -0
- package/dist/single-job-warning.js.map +1 -0
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +3 -1
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +1 -1
- package/dist/spec-complete.d.ts +13 -6
- package/dist/spec-complete.d.ts.map +1 -1
- package/dist/spec-complete.js +23 -11
- package/dist/spec-complete.js.map +1 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +47 -14
- package/dist/start.js.map +1 -1
- package/dist/status.d.ts +12 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +11 -3
- package/dist/status.js.map +1 -1
- package/dist/tasking.d.ts +246 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +452 -358
- package/dist/tasking.js.map +1 -1
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +21 -2
- package/dist/triage-persist.js.map +1 -1
- package/dist/verify-workflow-template.d.ts.map +1 -1
- package/dist/verify-workflow-template.js +8 -4
- package/dist/verify-workflow-template.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +4 -0
- package/dist/verify.js.map +1 -1
- package/dist/watch-session.d.ts +42 -13
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +52 -23
- package/dist/watch-session.js.map +1 -1
- package/dist/work-layout.d.ts +10 -4
- package/dist/work-layout.d.ts.map +1 -1
- package/dist/work-layout.js +11 -3
- package/dist/work-layout.js.map +1 -1
- package/dist/workspace.d.ts +65 -4
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +159 -11
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
- package/src/advance-ci-template.ts +80 -81
- package/src/advance-drivers.ts +45 -6
- package/src/advance-isolated.ts +3 -5
- package/src/advance-lifecycle-template.ts +375 -538
- package/src/advance-loop-driver.ts +3 -5
- package/src/advance-treeless-publish.ts +61 -0
- package/src/advance.ts +145 -15
- package/src/advancing-lock.ts +36 -1
- package/src/agent-env.ts +174 -0
- package/src/agent-stop.ts +81 -0
- package/src/apply-merge-action.ts +401 -154
- package/src/apply-stuck-action.ts +2 -2
- package/src/ci-agent-result.ts +421 -0
- package/src/ci-handoff-apply.ts +737 -0
- package/src/ci-handoff-format.ts +926 -0
- package/src/ci-handoff-lfs.ts +519 -0
- package/src/ci-handoff.ts +392 -0
- package/src/ci-lock-outputs.ts +273 -0
- package/src/ci-phase-build.ts +1328 -0
- package/src/ci-phase-driver.ts +548 -0
- package/src/ci-phase-intake.ts +861 -0
- package/src/ci-phase-tasking.ts +1255 -0
- package/src/ci-phase-treeless.ts +2028 -0
- package/src/ci-read-token.ts +90 -0
- package/src/cli.ts +591 -29
- package/src/close-job-template.ts +52 -15
- package/src/close-job.ts +77 -12
- package/src/complete.ts +19 -1
- package/src/config.ts +43 -48
- package/src/continue-branch.ts +121 -16
- package/src/crashed-run-save.ts +162 -0
- package/src/cwd-section.ts +10 -0
- package/src/decision-engine.ts +10 -0
- package/src/do-config.ts +2 -57
- package/src/do.ts +170 -28
- package/src/dorfl-item-template.ts +556 -0
- package/src/env-config.ts +7 -5
- package/src/frontmatter.ts +155 -2
- package/src/gc.ts +27 -2
- package/src/git.ts +83 -5
- package/src/harness.ts +58 -6
- package/src/index.ts +50 -1
- package/src/install-ci-action-pins.ts +209 -0
- package/src/install-ci-actions-pr-creation.ts +186 -0
- package/src/install-ci-capabilities/dorfl-item.ts +33 -0
- package/src/install-ci-core.ts +388 -233
- package/src/install-ci-github.ts +149 -1
- package/src/install-ci.ts +83 -37
- package/src/intake-marker.ts +6 -1
- package/src/intake-triage.ts +7 -3
- package/src/intake-trigger-template.ts +126 -327
- package/src/intake.ts +725 -544
- package/src/integration-core.ts +975 -523
- package/src/integrator.ts +28 -0
- package/src/isolation.ts +29 -10
- package/src/item-lock.ts +308 -92
- package/src/land-question-residue.ts +122 -0
- package/src/lifecycle-gather.ts +82 -10
- package/src/merge-answer.ts +82 -0
- package/src/merge-question-surfacer.ts +320 -112
- package/src/merge-question-tick.ts +182 -0
- package/src/mint-adr.ts +28 -2
- package/src/needs-attention.ts +350 -42
- package/src/phase-recorder.ts +350 -0
- package/src/phase.ts +177 -0
- package/src/pi-harness.ts +25 -5
- package/src/prepare.ts +4 -0
- package/src/prompt.ts +68 -1
- package/src/reap-branches.ts +107 -0
- package/src/ref-write.ts +324 -0
- package/src/repo-config.ts +8 -8
- package/src/repo-mirror.ts +107 -1
- package/src/review-verdict.ts +1 -1
- package/src/run.ts +47 -6
- package/src/scan.ts +37 -7
- package/src/sidecar.ts +36 -0
- package/src/single-job-warning.ts +135 -0
- package/src/spec-complete.ts +36 -14
- package/src/start.ts +55 -16
- package/src/status.ts +29 -3
- package/src/tasking.ts +593 -378
- package/src/triage-persist.ts +24 -2
- package/src/verify-workflow-template.ts +8 -4
- package/src/verify.ts +4 -0
- package/src/watch-session.ts +86 -28
- package/src/work-layout.ts +11 -3
- package/src/workspace.ts +210 -14
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The CI handoff artifact on disk**: the writer (agent job) and the safe
|
|
3
|
+
* reader (apply job). The record format and its intent table are
|
|
4
|
+
* `ci-handoff-format.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The artifact directory holds exactly:
|
|
7
|
+
*
|
|
8
|
+
* - `handoff.json`: the record;
|
|
9
|
+
* - `work.bundle`: for code-carrying intents only, `git bundle create
|
|
10
|
+
* work.bundle <work-branch> ^<baseSha>`, exactly one ref;
|
|
11
|
+
* - `lfs/<oid>`: the object of every LFS pointer the bundle's new commits
|
|
12
|
+
* add or change (`ci-handoff-lfs.ts`; the reader here checks only their
|
|
13
|
+
* names and sizes, the apply validation checks them against the pointers).
|
|
14
|
+
*
|
|
15
|
+
* The reader treats the directory as HOSTILE (the agent controls everything in
|
|
16
|
+
* its job): it must sit under `$RUNNER_TEMP`, only those names are read, a
|
|
17
|
+
* symlink or any other entry rejects the whole handoff, files are opened with
|
|
18
|
+
* `O_NOFOLLOW`, and every size limit is checked before anything is parsed. The
|
|
19
|
+
* bundle header is parsed here without running git on hostile bytes; the
|
|
20
|
+
* bundle's CONTENT (history, protected paths) is task
|
|
21
|
+
* `ci-split-apply-rejects-hostile-bundle`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
closeSync,
|
|
26
|
+
constants as fsConstants,
|
|
27
|
+
fstatSync,
|
|
28
|
+
lstatSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
openSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
readSync,
|
|
33
|
+
realpathSync,
|
|
34
|
+
statSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
type Stats,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import {isAbsolute, join, relative, sep} from 'node:path';
|
|
39
|
+
import {git} from './git.js';
|
|
40
|
+
import {writeLfsObjects} from './ci-handoff-lfs.js';
|
|
41
|
+
import {
|
|
42
|
+
canonicalHandoffItem,
|
|
43
|
+
handoffByteLimits,
|
|
44
|
+
intentRowFor,
|
|
45
|
+
rejectHandoff as reject,
|
|
46
|
+
validateHandoffRecord,
|
|
47
|
+
type HandoffRecord,
|
|
48
|
+
type HandoffRung,
|
|
49
|
+
type HandoffTrust,
|
|
50
|
+
type IntentRow,
|
|
51
|
+
} from './ci-handoff-format.js';
|
|
52
|
+
|
|
53
|
+
/** The record file of the artifact. */
|
|
54
|
+
export const HANDOFF_JSON = 'handoff.json';
|
|
55
|
+
/** The git bundle of the work branch (code-carrying intents only). */
|
|
56
|
+
export const HANDOFF_BUNDLE = 'work.bundle';
|
|
57
|
+
/** The directory of LFS objects, one file per oid. */
|
|
58
|
+
export const HANDOFF_LFS_DIR = 'lfs';
|
|
59
|
+
|
|
60
|
+
/** The most a bundle header (signature, capabilities, prerequisites, one ref) may take. */
|
|
61
|
+
const MAX_BUNDLE_HEADER_BYTES = 64 * 1024;
|
|
62
|
+
|
|
63
|
+
const HEX_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
64
|
+
const LFS_OID = /^[0-9a-f]{64}$/;
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Bundle header
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** What a bundle header says: its one ref, the ref's tip and the prerequisites. */
|
|
71
|
+
export interface BundleHeader {
|
|
72
|
+
ref: string;
|
|
73
|
+
tip: string;
|
|
74
|
+
prerequisites: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse the header of a git bundle (v2, or v3 with only `@object-format`)
|
|
79
|
+
* without running git, and require exactly one ref.
|
|
80
|
+
*/
|
|
81
|
+
export function parseBundleHeader(head: Buffer): BundleHeader {
|
|
82
|
+
const end = head.indexOf('\n\n');
|
|
83
|
+
if (end === -1) reject('bundle-format', 'no bundle header terminator');
|
|
84
|
+
const lines = head.subarray(0, end).toString('latin1').split('\n');
|
|
85
|
+
const signature = lines.shift();
|
|
86
|
+
const v3 = signature === '# v3 git bundle';
|
|
87
|
+
if (signature !== '# v2 git bundle' && !v3) {
|
|
88
|
+
reject('bundle-format', 'not a v2 or v3 git bundle');
|
|
89
|
+
}
|
|
90
|
+
const prerequisites: string[] = [];
|
|
91
|
+
const refs: {tip: string; ref: string}[] = [];
|
|
92
|
+
for (const l of lines) {
|
|
93
|
+
if (l.startsWith('@')) {
|
|
94
|
+
if (!v3 || !/^@object-format=(?:sha1|sha256)$/.test(l)) {
|
|
95
|
+
reject('bundle-format', 'unsupported bundle capability');
|
|
96
|
+
}
|
|
97
|
+
} else if (l.startsWith('-')) {
|
|
98
|
+
const oid = l.slice(1).split(' ', 1)[0];
|
|
99
|
+
if (!HEX_OID.test(oid)) reject('bundle-format', 'bad prerequisite line');
|
|
100
|
+
prerequisites.push(oid);
|
|
101
|
+
} else {
|
|
102
|
+
const m = /^([0-9a-f]{40}|[0-9a-f]{64}) (refs\/[\x21-\x7e]+)$/.exec(l);
|
|
103
|
+
if (m === null) reject('bundle-format', 'bad bundle ref line');
|
|
104
|
+
refs.push({tip: m[1], ref: m[2]});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (refs.length !== 1) {
|
|
108
|
+
reject('bundle-refs', `the bundle carries ${refs.length} refs, not 1`);
|
|
109
|
+
}
|
|
110
|
+
return {...refs[0], prerequisites};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Low-level safe reads
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Open a path for reading, refusing a symlink at open time (`O_NOFOLLOW`). */
|
|
118
|
+
function openNoFollow(path: string, name: string): number {
|
|
119
|
+
try {
|
|
120
|
+
return openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
121
|
+
} catch {
|
|
122
|
+
reject('symlink', `${name} could not be opened as a regular file`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read a regular file of at most `max` bytes (whole, or only its first
|
|
128
|
+
* `headOnly` bytes), refusing a symlink and re-checking the size on the open
|
|
129
|
+
* descriptor so a file swapped after the directory scan is still bounded.
|
|
130
|
+
*/
|
|
131
|
+
function readBounded(
|
|
132
|
+
path: string,
|
|
133
|
+
name: string,
|
|
134
|
+
max: number,
|
|
135
|
+
headOnly?: number,
|
|
136
|
+
): Buffer {
|
|
137
|
+
const fd = openNoFollow(path, name);
|
|
138
|
+
try {
|
|
139
|
+
const st = fstatSync(fd);
|
|
140
|
+
if (!st.isFile()) reject('layout', `${name} is not a regular file`);
|
|
141
|
+
if (st.size > max) reject('size', `${name} is over ${max} bytes`);
|
|
142
|
+
const want = headOnly ?? max + 1;
|
|
143
|
+
const buf = Buffer.alloc(Math.min(want, st.size + 1));
|
|
144
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
145
|
+
if (headOnly === undefined && n > max) {
|
|
146
|
+
reject('size', `${name} is over ${max} bytes`);
|
|
147
|
+
}
|
|
148
|
+
return buf.subarray(0, n);
|
|
149
|
+
} finally {
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `lstat` an entry; refuse a symlink or an entry of the wrong type. */
|
|
155
|
+
function entryStat(path: string, name: string, want: 'file' | 'dir'): Stats {
|
|
156
|
+
const st = lstatSync(path);
|
|
157
|
+
if (st.isSymbolicLink()) reject('symlink', `${name} is a symlink`);
|
|
158
|
+
const ok = want === 'file' ? st.isFile() : st.isDirectory();
|
|
159
|
+
if (!ok) {
|
|
160
|
+
reject(
|
|
161
|
+
'layout',
|
|
162
|
+
`${name} is not a ${want === 'file' ? 'file' : 'directory'}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return st;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isStrictlyInside(root: string, path: string): boolean {
|
|
169
|
+
const rel = relative(root, path);
|
|
170
|
+
return (
|
|
171
|
+
rel !== '' &&
|
|
172
|
+
rel !== '..' &&
|
|
173
|
+
!rel.startsWith(`..${sep}`) &&
|
|
174
|
+
!isAbsolute(rel)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function checkBundlePresence(row: IntentRow, present: boolean): void {
|
|
179
|
+
if (row.bundle.rule === 'required' && !present) {
|
|
180
|
+
reject('bundle-presence', `${row.kind} needs ${HANDOFF_BUNDLE}`);
|
|
181
|
+
}
|
|
182
|
+
if (row.bundle.rule === 'none' && present) {
|
|
183
|
+
reject('bundle-presence', `${row.kind} carries no ${HANDOFF_BUNDLE}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Writing (agent side)
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** What {@link writeHandoff} needs to also write `work.bundle`. */
|
|
192
|
+
export interface HandoffBundleSource {
|
|
193
|
+
/** The repository (the agent job's checkout). */
|
|
194
|
+
repo: string;
|
|
195
|
+
/** The work branch name; the bundle's one ref is `refs/heads/<workBranch>`. */
|
|
196
|
+
workBranch: string;
|
|
197
|
+
/** The trusted base: the bundle holds `<workBranch> ^<baseSha>`. */
|
|
198
|
+
baseSha: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Write the handoff into `dir` (created, and required to be empty): validate
|
|
203
|
+
* the record exactly as the reader will, then write `handoff.json` and, for a
|
|
204
|
+
* code-carrying intent, `work.bundle` with exactly one ref, and `lfs/<oid>` for
|
|
205
|
+
* the object of every LFS pointer its new commits add or change (from the
|
|
206
|
+
* local store; one the store lacks is left out and returned in `lfsMissing`,
|
|
207
|
+
* and the apply job then rejects the handoff naming it). The record's item is
|
|
208
|
+
* written in its canonical form.
|
|
209
|
+
*/
|
|
210
|
+
export function writeHandoff(params: {
|
|
211
|
+
dir: string;
|
|
212
|
+
rung: HandoffRung;
|
|
213
|
+
record: HandoffRecord;
|
|
214
|
+
bundle?: HandoffBundleSource;
|
|
215
|
+
}): {lfsMissing: string[]} {
|
|
216
|
+
const {dir, rung, bundle} = params;
|
|
217
|
+
const item = canonicalHandoffItem(params.record.item);
|
|
218
|
+
const json = JSON.stringify({...params.record, item});
|
|
219
|
+
const {row} = validateHandoffRecord(JSON.parse(json), {item, rung});
|
|
220
|
+
checkBundlePresence(row, bundle !== undefined);
|
|
221
|
+
if (Buffer.byteLength(json) > handoffByteLimits().handoffJsonBytes) {
|
|
222
|
+
reject(
|
|
223
|
+
'size',
|
|
224
|
+
`${HANDOFF_JSON} is over ${handoffByteLimits().handoffJsonBytes} bytes`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
mkdirSync(dir, {recursive: true});
|
|
228
|
+
if (readdirSync(dir).length > 0) {
|
|
229
|
+
reject('layout', `the handoff directory ${dir} is not empty`);
|
|
230
|
+
}
|
|
231
|
+
writeFileSync(join(dir, HANDOFF_JSON), json);
|
|
232
|
+
if (bundle === undefined) return {lfsMissing: []};
|
|
233
|
+
writeBundle(dir, bundle);
|
|
234
|
+
const {missing} = writeLfsObjects({
|
|
235
|
+
dir,
|
|
236
|
+
repo: bundle.repo,
|
|
237
|
+
ref: `refs/heads/${bundle.workBranch}`,
|
|
238
|
+
baseSha: bundle.baseSha,
|
|
239
|
+
});
|
|
240
|
+
return {lfsMissing: missing};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `git bundle create work.bundle refs/heads/<branch> ^<baseSha>`, then check it. */
|
|
244
|
+
function writeBundle(dir: string, source: HandoffBundleSource): void {
|
|
245
|
+
if (!HEX_OID.test(source.baseSha)) {
|
|
246
|
+
reject('bundle-format', 'baseSha is not a full commit id');
|
|
247
|
+
}
|
|
248
|
+
const ref = `refs/heads/${source.workBranch}`;
|
|
249
|
+
git(['check-ref-format', ref], source.repo);
|
|
250
|
+
const out = join(dir, HANDOFF_BUNDLE);
|
|
251
|
+
git(['bundle', 'create', out, ref, `^${source.baseSha}`], source.repo);
|
|
252
|
+
const header = parseBundleHeader(
|
|
253
|
+
readBounded(
|
|
254
|
+
out,
|
|
255
|
+
HANDOFF_BUNDLE,
|
|
256
|
+
Number.MAX_SAFE_INTEGER,
|
|
257
|
+
MAX_BUNDLE_HEADER_BYTES,
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
if (header.ref !== ref) {
|
|
261
|
+
reject('bundle-refs', `the bundle carries ${header.ref}, not ${ref}`);
|
|
262
|
+
}
|
|
263
|
+
if (statSync(out).size > handoffByteLimits().bundleBytes) {
|
|
264
|
+
reject(
|
|
265
|
+
'size',
|
|
266
|
+
`${HANDOFF_BUNDLE} is over ${handoffByteLimits().bundleBytes} bytes`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Reading (apply side)
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** An LFS object file of the artifact (checked against pointers elsewhere). */
|
|
276
|
+
export interface HandoffLfsObject {
|
|
277
|
+
oid: string;
|
|
278
|
+
path: string;
|
|
279
|
+
size: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A read, validated handoff. */
|
|
283
|
+
export interface ReadHandoff {
|
|
284
|
+
record: HandoffRecord;
|
|
285
|
+
/** The intent table row the record was validated against. */
|
|
286
|
+
row: IntentRow;
|
|
287
|
+
/** `work.bundle` with its parsed header, when the artifact carries one. */
|
|
288
|
+
bundle?: {path: string; size: number} & BundleHeader;
|
|
289
|
+
lfsObjects: HandoffLfsObject[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Read and validate the handoff the agent job uploaded. `dir` is where the
|
|
294
|
+
* artifact was downloaded: a real directory strictly inside `runnerTemp`
|
|
295
|
+
* (`$RUNNER_TEMP`). Rejects ({@link HandoffRejected}) a symlink anywhere, any
|
|
296
|
+
* entry but `handoff.json`, `work.bundle` and `lfs/<64-hex oid>`, any size over
|
|
297
|
+
* the byte limits (`handoffByteLimits`), a record that fails `validateHandoffRecord`, a bundle
|
|
298
|
+
* the intent forbids or lacks, `lfs/` without a bundle, and a bundle whose
|
|
299
|
+
* header is malformed or carries other than one ref.
|
|
300
|
+
*/
|
|
301
|
+
export function readHandoff(params: {
|
|
302
|
+
dir: string;
|
|
303
|
+
runnerTemp: string;
|
|
304
|
+
trust: HandoffTrust;
|
|
305
|
+
}): ReadHandoff {
|
|
306
|
+
const {dir, runnerTemp, trust} = params;
|
|
307
|
+
const rootStat = lstatSync(dir, {throwIfNoEntry: false});
|
|
308
|
+
if (rootStat === undefined) {
|
|
309
|
+
reject('layout', 'the handoff directory is missing');
|
|
310
|
+
}
|
|
311
|
+
if (rootStat.isSymbolicLink()) {
|
|
312
|
+
reject('symlink', 'the handoff directory is a symlink');
|
|
313
|
+
}
|
|
314
|
+
if (!rootStat.isDirectory()) {
|
|
315
|
+
reject('layout', 'the handoff path is not a directory');
|
|
316
|
+
}
|
|
317
|
+
if (!isStrictlyInside(realpathSync(runnerTemp), realpathSync(dir))) {
|
|
318
|
+
reject('location', 'the handoff directory is not under $RUNNER_TEMP');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let total = 0;
|
|
322
|
+
let hasJson = false;
|
|
323
|
+
let hasBundle = false;
|
|
324
|
+
let lfsTotal = 0;
|
|
325
|
+
const lfsObjects: HandoffLfsObject[] = [];
|
|
326
|
+
for (const name of readdirSync(dir)) {
|
|
327
|
+
const path = join(dir, name);
|
|
328
|
+
if (name === HANDOFF_JSON) {
|
|
329
|
+
total += entryStat(path, name, 'file').size;
|
|
330
|
+
hasJson = true;
|
|
331
|
+
} else if (name === HANDOFF_BUNDLE) {
|
|
332
|
+
total += entryStat(path, name, 'file').size;
|
|
333
|
+
hasBundle = true;
|
|
334
|
+
} else if (name === HANDOFF_LFS_DIR) {
|
|
335
|
+
entryStat(path, name, 'dir');
|
|
336
|
+
for (const oid of readdirSync(path)) {
|
|
337
|
+
const objName = `${HANDOFF_LFS_DIR}/${JSON.stringify(oid).slice(0, 80)}`;
|
|
338
|
+
if (!LFS_OID.test(oid)) reject('layout', `unexpected entry ${objName}`);
|
|
339
|
+
const objPath = join(path, oid);
|
|
340
|
+
const size = entryStat(objPath, objName, 'file').size;
|
|
341
|
+
lfsTotal += size;
|
|
342
|
+
lfsObjects.push({oid, path: objPath, size});
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
reject('layout', `unexpected entry ${JSON.stringify(name).slice(0, 80)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!hasJson) reject('layout', `${HANDOFF_JSON} is missing`);
|
|
349
|
+
if (lfsTotal > handoffByteLimits().lfsBytes) {
|
|
350
|
+
reject(
|
|
351
|
+
'size',
|
|
352
|
+
`the LFS objects are over ${handoffByteLimits().lfsBytes} bytes`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
total += lfsTotal;
|
|
356
|
+
if (total > handoffByteLimits().artifactBytes) {
|
|
357
|
+
reject(
|
|
358
|
+
'size',
|
|
359
|
+
`the artifact is over ${handoffByteLimits().artifactBytes} bytes`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (lfsObjects.length > 0 && !hasBundle) {
|
|
363
|
+
reject('layout', `${HANDOFF_LFS_DIR}/ without ${HANDOFF_BUNDLE}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const raw = readBounded(
|
|
367
|
+
join(dir, HANDOFF_JSON),
|
|
368
|
+
HANDOFF_JSON,
|
|
369
|
+
handoffByteLimits().handoffJsonBytes,
|
|
370
|
+
);
|
|
371
|
+
let parsed: unknown;
|
|
372
|
+
try {
|
|
373
|
+
parsed = JSON.parse(raw.toString('utf8'));
|
|
374
|
+
} catch {
|
|
375
|
+
reject('json', `${HANDOFF_JSON} is not valid JSON`);
|
|
376
|
+
}
|
|
377
|
+
const {record, row} = validateHandoffRecord(parsed, trust);
|
|
378
|
+
checkBundlePresence(row, hasBundle);
|
|
379
|
+
|
|
380
|
+
let bundle: ReadHandoff['bundle'];
|
|
381
|
+
if (hasBundle) {
|
|
382
|
+
const path = join(dir, HANDOFF_BUNDLE);
|
|
383
|
+
const head = readBounded(
|
|
384
|
+
path,
|
|
385
|
+
HANDOFF_BUNDLE,
|
|
386
|
+
handoffByteLimits().bundleBytes,
|
|
387
|
+
MAX_BUNDLE_HEADER_BYTES,
|
|
388
|
+
);
|
|
389
|
+
bundle = {path, size: lstatSync(path).size, ...parseBundleHeader(head)};
|
|
390
|
+
}
|
|
391
|
+
return {record, row, bundle, lfsObjects};
|
|
392
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The lock job's `$GITHUB_OUTPUT` facts** (spec
|
|
3
|
+
* `ci-agent-job-without-write-token`).
|
|
4
|
+
*
|
|
5
|
+
* The lock job's outputs are the apply job's TRUSTED channel
|
|
6
|
+
* (`needs.lock.outputs.*`): they choose refs, rungs and artifact names. So the
|
|
7
|
+
* serializer emits only values that cannot carry attacker text: commit ids,
|
|
8
|
+
* enums, booleans, bounded integers (the agent timeout), provider-assigned
|
|
9
|
+
* comment ids and the dorfl-derived artifact name. Every key is known here with its type, and every
|
|
10
|
+
* value is re-checked at runtime (a TypeScript type does not stop a string read
|
|
11
|
+
* from an issue), so free text such as an issue title is REFUSED, never
|
|
12
|
+
* escaped.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {IntegrationMode} from './config.js';
|
|
16
|
+
import {
|
|
17
|
+
HANDOFF_NAME_RE,
|
|
18
|
+
HANDOFF_RUNGS,
|
|
19
|
+
type HandoffRung,
|
|
20
|
+
} from './ci-handoff-format.js';
|
|
21
|
+
|
|
22
|
+
/** The facts a lock job publishes (task `ci-split-generate-workflows` lists them). */
|
|
23
|
+
export interface LockOutputs {
|
|
24
|
+
/** Whether the lock job took the item's locks. */
|
|
25
|
+
acquired?: boolean;
|
|
26
|
+
/** `false` when the rung runs no agent (decision 11). */
|
|
27
|
+
needsAgent?: boolean;
|
|
28
|
+
/** The classified rung. */
|
|
29
|
+
rung?: HandoffRung;
|
|
30
|
+
/** The arbiter's `main` tip the item was classified against. */
|
|
31
|
+
baseSha?: string;
|
|
32
|
+
/** The lock ref's sha after the acquire. */
|
|
33
|
+
lockSha?: string;
|
|
34
|
+
/** The kept work branch's tip, when one exists (decision 7). */
|
|
35
|
+
continueTip?: string;
|
|
36
|
+
/** The artifact name (`handoffName`). */
|
|
37
|
+
handoffName?: string;
|
|
38
|
+
/** The agent job's timeout, from `dorfl.json` at `baseSha`. */
|
|
39
|
+
agentTimeoutMinutes?: number;
|
|
40
|
+
/** The intake origin trust. */
|
|
41
|
+
originTrust?: 'trusted' | 'untrusted';
|
|
42
|
+
/** The intake document mode. */
|
|
43
|
+
documentMode?: IntegrationMode;
|
|
44
|
+
/**
|
|
45
|
+
* The issue comment ids the lock job read (intake), as the issue provider
|
|
46
|
+
* reports them: a REST database id (a positive integer) or a GraphQL node id
|
|
47
|
+
* (`IC_kwDO...`, what `gh issue view --json comments` reports). A parsed
|
|
48
|
+
* integer id stays a number; a node id is a string.
|
|
49
|
+
*/
|
|
50
|
+
seenCommentIds?: Array<number | string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A lock output the serializer will not emit. */
|
|
54
|
+
export class LockOutputRefused extends Error {
|
|
55
|
+
constructor(message: string) {
|
|
56
|
+
super(`lock output refused: ${message}`);
|
|
57
|
+
this.name = 'LockOutputRefused';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The largest agent timeout accepted (GitHub's self-hosted job maximum: 5 days). */
|
|
62
|
+
export const MAX_AGENT_TIMEOUT_MINUTES = 7200;
|
|
63
|
+
/** The most comment ids one output may list. */
|
|
64
|
+
export const MAX_SEEN_COMMENT_IDS = 5000;
|
|
65
|
+
|
|
66
|
+
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A GitHub GraphQL node id (`IC_kwDODKw3uc7tpgEC`): an upper-case type prefix,
|
|
70
|
+
* `_`, then URL-safe base64. Assigned by GitHub, never chosen by an issue
|
|
71
|
+
* author; the charset holds no `,` (the list separator), `=`, space or line
|
|
72
|
+
* break, so it cannot forge another output line.
|
|
73
|
+
*/
|
|
74
|
+
const NODE_ID_RE = /^[A-Z]{1,8}_[A-Za-z0-9_-]{1,80}$/;
|
|
75
|
+
|
|
76
|
+
function refuse(key: string, why: string): never {
|
|
77
|
+
throw new LockOutputRefused(`${key} ${why}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function boolean(key: string, v: unknown): string {
|
|
81
|
+
if (typeof v !== 'boolean') refuse(key, 'is not a boolean');
|
|
82
|
+
return String(v);
|
|
83
|
+
}
|
|
84
|
+
function sha(key: string, v: unknown): string {
|
|
85
|
+
if (typeof v !== 'string' || !SHA_RE.test(v)) {
|
|
86
|
+
refuse(key, 'is not a full lower-case commit id');
|
|
87
|
+
}
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
function oneOf(values: readonly string[]) {
|
|
91
|
+
return (key: string, v: unknown): string => {
|
|
92
|
+
if (typeof v !== 'string' || !values.includes(v)) {
|
|
93
|
+
refuse(key, `is not one of ${values.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
return v;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function integer(min: number, max: number) {
|
|
99
|
+
return (key: string, v: unknown): string => {
|
|
100
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max) {
|
|
101
|
+
refuse(key, `is not an integer in ${min}..${max}`);
|
|
102
|
+
}
|
|
103
|
+
return String(v);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every key, in output order, with its value check. */
|
|
108
|
+
const SCHEMA: Record<keyof LockOutputs, (key: string, v: unknown) => string> = {
|
|
109
|
+
acquired: boolean,
|
|
110
|
+
needsAgent: boolean,
|
|
111
|
+
rung: oneOf(HANDOFF_RUNGS),
|
|
112
|
+
baseSha: sha,
|
|
113
|
+
lockSha: sha,
|
|
114
|
+
continueTip: sha,
|
|
115
|
+
handoffName: (key, v) => {
|
|
116
|
+
if (typeof v !== 'string' || !HANDOFF_NAME_RE.test(v)) {
|
|
117
|
+
refuse(key, 'is not a dorfl handoff artifact name');
|
|
118
|
+
}
|
|
119
|
+
return v;
|
|
120
|
+
},
|
|
121
|
+
agentTimeoutMinutes: integer(1, MAX_AGENT_TIMEOUT_MINUTES),
|
|
122
|
+
originTrust: oneOf(['trusted', 'untrusted']),
|
|
123
|
+
documentMode: oneOf(['propose', 'merge']),
|
|
124
|
+
seenCommentIds: (key, v) => {
|
|
125
|
+
if (!Array.isArray(v) || v.length > MAX_SEEN_COMMENT_IDS) {
|
|
126
|
+
refuse(key, `is not a list of at most ${MAX_SEEN_COMMENT_IDS} ids`);
|
|
127
|
+
}
|
|
128
|
+
const check = integer(1, Number.MAX_SAFE_INTEGER);
|
|
129
|
+
return v
|
|
130
|
+
.map((id) =>
|
|
131
|
+
typeof id === 'string' && NODE_ID_RE.test(id) ? id : check(key, id),
|
|
132
|
+
)
|
|
133
|
+
.join(',');
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every lock output key, in output order. The generated lock job declares
|
|
139
|
+
* exactly these as its job `outputs` (task `ci-split-generate-workflows`), so
|
|
140
|
+
* `toJSON(needs.lock.outputs)` carries no key {@link parseLockOutputs} refuses.
|
|
141
|
+
*/
|
|
142
|
+
export const LOCK_OUTPUT_KEYS = Object.keys(SCHEMA) as ReadonlyArray<
|
|
143
|
+
keyof LockOutputs
|
|
144
|
+
>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Serialize lock facts as `$GITHUB_OUTPUT` lines (`key=value\n`, in a fixed
|
|
148
|
+
* order; an `undefined` fact is omitted). Throws {@link LockOutputRefused} for
|
|
149
|
+
* an unknown key or any value outside its type, so free text never reaches the
|
|
150
|
+
* trusted channel.
|
|
151
|
+
*/
|
|
152
|
+
export function serializeLockOutputs(facts: LockOutputs): string {
|
|
153
|
+
for (const key of Object.keys(facts)) {
|
|
154
|
+
if (!Object.hasOwn(SCHEMA, key)) {
|
|
155
|
+
refuse(JSON.stringify(key).slice(0, 80), 'is not a lock output');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
let out = '';
|
|
159
|
+
for (const [key, check] of Object.entries(SCHEMA)) {
|
|
160
|
+
const v = (facts as Record<string, unknown>)[key];
|
|
161
|
+
if (v === undefined) continue;
|
|
162
|
+
out += `${key}=${check(key, v)}\n`;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Reading the facts back (the agent and apply jobs)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The environment variable the agent and apply jobs receive the lock job's
|
|
173
|
+
* outputs in: the JSON object `toJSON(needs.lock.outputs)` (every value a
|
|
174
|
+
* string, an unset output an empty string), passed through `env:` like every
|
|
175
|
+
* other value that reaches a `run:` script.
|
|
176
|
+
*/
|
|
177
|
+
export const LOCK_OUTPUTS_ENV = 'DORFL_LOCK_OUTPUTS';
|
|
178
|
+
|
|
179
|
+
const PARSE: Record<keyof LockOutputs, (key: string, raw: string) => unknown> =
|
|
180
|
+
{
|
|
181
|
+
acquired: parseBoolean,
|
|
182
|
+
needsAgent: parseBoolean,
|
|
183
|
+
rung: (key, raw) => {
|
|
184
|
+
SCHEMA.rung(key, raw);
|
|
185
|
+
return raw;
|
|
186
|
+
},
|
|
187
|
+
baseSha: (key, raw) => sha(key, raw),
|
|
188
|
+
lockSha: (key, raw) => sha(key, raw),
|
|
189
|
+
continueTip: (key, raw) => sha(key, raw),
|
|
190
|
+
handoffName: (key, raw) => SCHEMA.handoffName(key, raw),
|
|
191
|
+
agentTimeoutMinutes: (key, raw) => {
|
|
192
|
+
const n = /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
|
|
193
|
+
SCHEMA.agentTimeoutMinutes(key, n);
|
|
194
|
+
return n;
|
|
195
|
+
},
|
|
196
|
+
originTrust: (key, raw) => SCHEMA.originTrust(key, raw),
|
|
197
|
+
documentMode: (key, raw) => SCHEMA.documentMode(key, raw),
|
|
198
|
+
seenCommentIds: (key, raw) => {
|
|
199
|
+
const ids = raw.split(',').map((part): number | string => {
|
|
200
|
+
if (NODE_ID_RE.test(part)) return part;
|
|
201
|
+
if (!/^[0-9]{1,16}$/.test(part)) {
|
|
202
|
+
refuse(key, 'is not a comma-separated list of ids');
|
|
203
|
+
}
|
|
204
|
+
return Number(part);
|
|
205
|
+
});
|
|
206
|
+
SCHEMA.seenCommentIds(key, ids);
|
|
207
|
+
return ids;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function parseBoolean(key: string, raw: string): boolean {
|
|
212
|
+
if (raw !== 'true' && raw !== 'false') refuse(key, 'is not a boolean');
|
|
213
|
+
return raw === 'true';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse the lock job's outputs as the agent and apply jobs receive them (a
|
|
218
|
+
* `key -> string` object), re-checking every value with the same rules
|
|
219
|
+
* {@link serializeLockOutputs} applied. An empty string is an unset output
|
|
220
|
+
* (GitHub renders an output the job never wrote as `''`). Throws
|
|
221
|
+
* {@link LockOutputRefused} for an unknown key or a malformed value.
|
|
222
|
+
*/
|
|
223
|
+
export function parseLockOutputs(raw: unknown): LockOutputs {
|
|
224
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
225
|
+
throw new LockOutputRefused('the lock outputs are not an object');
|
|
226
|
+
}
|
|
227
|
+
const facts: Record<string, unknown> = {};
|
|
228
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
229
|
+
if (!Object.hasOwn(PARSE, key)) {
|
|
230
|
+
refuse(JSON.stringify(key).slice(0, 80), 'is not a lock output');
|
|
231
|
+
}
|
|
232
|
+
if (value === '' || value === undefined || value === null) continue;
|
|
233
|
+
if (typeof value !== 'string') refuse(key, 'is not a string');
|
|
234
|
+
facts[key] = PARSE[key as keyof LockOutputs](key, value);
|
|
235
|
+
}
|
|
236
|
+
return facts as LockOutputs;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse `$GITHUB_OUTPUT` lines as {@link serializeLockOutputs} writes them
|
|
241
|
+
* (`key=value`, one per line), for tests and local replays of the lock job.
|
|
242
|
+
*/
|
|
243
|
+
export function parseLockOutputLines(text: string): LockOutputs {
|
|
244
|
+
const raw: Record<string, string> = {};
|
|
245
|
+
for (const line of text.split('\n')) {
|
|
246
|
+
if (line === '') continue;
|
|
247
|
+
const eq = line.indexOf('=');
|
|
248
|
+
if (eq <= 0) throw new LockOutputRefused('a line is not key=value');
|
|
249
|
+
raw[line.slice(0, eq)] = line.slice(eq + 1);
|
|
250
|
+
}
|
|
251
|
+
return parseLockOutputs(raw);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read the lock job's outputs from {@link LOCK_OUTPUTS_ENV}. Throws
|
|
256
|
+
* {@link LockOutputRefused} when it is missing or malformed: the agent and
|
|
257
|
+
* apply phases act on nothing but these trusted facts.
|
|
258
|
+
*/
|
|
259
|
+
export function readLockOutputsFromEnv(env: NodeJS.ProcessEnv): LockOutputs {
|
|
260
|
+
const raw = env[LOCK_OUTPUTS_ENV];
|
|
261
|
+
if (raw === undefined || raw.trim() === '') {
|
|
262
|
+
throw new LockOutputRefused(
|
|
263
|
+
`${LOCK_OUTPUTS_ENV} is not set (the lock job's outputs, as JSON)`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
let parsed: unknown;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(raw);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new LockOutputRefused(`${LOCK_OUTPUTS_ENV} is not valid JSON`);
|
|
271
|
+
}
|
|
272
|
+
return parseLockOutputs(parsed);
|
|
273
|
+
}
|