ReviewOS

also looking at this

stacks/drone

test: control purge history preparation clock

#55
Merged chris wants to merge codex/purge-history-setup-clock into main
37 files +1804 -168
.github/workflows/ci.ymlmodified+11-0
Changes to .github/workflows/ci.yml
@@ -30,6 +30,8 @@ jobs:
3030 - run: bun run parity:check
3131 - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py
3232 - run: python3 -m unittest discover -s tests/hardware
33 - name: Verify a kernel-confirmed full input pipe on Linux
34 run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO'
3335 - run: ./buddy test
3436 - run: bun run runtime:cli
3537 - name: Verify signed test-image staging through the portable CLI outside the checkout
@@ -44,6 +46,15 @@ jobs:
4446 run: bun tests/fixtures/updates/qualify-preparation.ts "$RUNNER_TEMP/runtime-preparation-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4547 - name: Verify owned candidate identity and retirement with a copied standalone CLI
4648 run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
49 - name: Verify host slots, observed health and interrupted recovery with a copied standalone CLI
50 run: bun tests/fixtures/updates/qualify-host-slots.ts "$RUNNER_TEMP/runtime-host-slots-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$GITHUB_WORKSPACE"
51 - uses: actions/upload-artifact@v4
52 if: always()
53 with:
54 name: runtime-host-slots-evidence
55 if-no-files-found: ignore
56 retention-days: 7
57 path: ${{ runner.temp }}/runtime-host-slots-qualification
4758 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4859 working-directory: ${{ runner.temp }}
4960 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+4-2
Changes to README.md
@@ -270,8 +270,10 @@ store retains verified metadata, clock/release floors and historical recovery;
270270`updates:prepare` authenticates actual image bytes under that protected state.
271271`updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate
272272identity, recorded-byte status and explicit retirement while retaining files/history.
273Physical logging/commissioning, key revocation/repair, installed/known-good slots
274and image-bound boot/session health remain pending.
273`updates:trial`, `updates:slots`, `updates:rollback` and `updates:abandon` add retained
274host slots, actual image/boot/session health and fresh-policy rollback for the owned
275neutral host build. Physical logging/commissioning, key repair, complete storage-fault
276coverage and board image/install/boot/rollback qualification remain pending.
275277
276278## Sensor acquisition and replay
277279
app/Commands/UpdatesAbandon.tsadded+17-0
Changes to app/Commands/UpdatesAbandon.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { abandonRuntimeFirmwareTrial } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:abandon <directory>',
8 description: 'Abandon an exact interrupted host trial after metadata recovery while retaining artifacts and known-good state',
9 options: { ...storeReferenceFlags, '--trial <id>': 'Exact interrupted host trial identity' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.trial !== 'string') throw new Error('Explicit interrupted trial required')
13 await printAsync(JSON.stringify(await abandonRuntimeFirmwareTrial({ ...storeCommandReference(options, directory), trialId: options.trial })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
app/Commands/UpdatesRollback.tsadded+14-0
Changes to app/Commands/UpdatesRollback.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { hostTrialCommandOptions, hostTrialFlags, storeCommandFailure } from '../../resources/functions/drone/updates/command'
4import { rollbackRuntimeFirmwareSlot } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:rollback <directory>',
8 description: 'Re-verify the retained known-good host image under current policy and observe a fresh health session',
9 options: hostTrialFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await rollbackRuntimeFirmwareSlot(hostTrialCommandOptions(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesSlots.tsadded+14-0
Changes to app/Commands/UpdatesSlots.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { inspectRuntimeFirmwareSlots } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:slots <directory>',
8 description: 'Inspect retained host slot roles, evidence and recorded-byte integrity without current trust authority',
9 options: storeReferenceFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectRuntimeFirmwareSlots(storeCommandReference(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesTrial.tsadded+17-0
Changes to app/Commands/UpdatesTrial.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { hostTrialCommandOptions, hostTrialFlags, storeCommandFailure } from '../../resources/functions/drone/updates/command'
4import { trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:trial <directory>',
8 description: 'Confirm an owned host image only through current signed policy and its actual neutral runtime health',
9 options: { ...hostTrialFlags, '--candidate <id>': 'Exact owned prepared candidate identity' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.candidate !== 'string') throw new Error('Explicit owned candidate required')
13 await printAsync(JSON.stringify(await trialRuntimeFirmwareCandidate({ ...hostTrialCommandOptions(options, directory), candidateId: options.candidate })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
bin/drone.tsmodified+8-0
Changes to bin/drone.ts
@@ -31,6 +31,10 @@ import UpdatesPrepare from '../app/Commands/UpdatesPrepare'
3131import UpdatesReserve from '../app/Commands/UpdatesReserve'
3232import UpdatesCandidates from '../app/Commands/UpdatesCandidates'
3333import UpdatesRetire from '../app/Commands/UpdatesRetire'
34import UpdatesTrial from '../app/Commands/UpdatesTrial'
35import UpdatesRollback from '../app/Commands/UpdatesRollback'
36import UpdatesSlots from '../app/Commands/UpdatesSlots'
37import UpdatesAbandon from '../app/Commands/UpdatesAbandon'
3438
3539import MissionsSimulate from '../app/Commands/MissionsSimulate'
3640import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -105,6 +109,10 @@ UpdatesPrepare(app)
105109UpdatesReserve(app)
106110UpdatesCandidates(app)
107111UpdatesRetire(app)
112UpdatesTrial(app)
113UpdatesRollback(app)
114UpdatesSlots(app)
115UpdatesAbandon(app)
108116MissionsSimulate(app)
109117MissionsValidate(app)
110118MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdmodified+3-2
Changes to docs/adr/0028-runtime-trust-store.md
@@ -67,6 +67,7 @@ directories inside the private store. Explicit retirement preserves the files
6767and history. Metadata-only recovery cannot promote a reserved candidate based on
6868its receipt. Recorded-byte status is not current-policy authorization.
6969
70Installed and known-good slot roles and health confirmation bound to an exact
71image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
70The [host slot health decision](0029-host-slot-health.md) extends this protocol to
71format3 with retained host images and actual owned runtime confirmation. No receipt
72or caller-supplied healthy boolean can select firmware. Board installation,
7273bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/adr/0029-host-slot-health.mdadded+63-0
Changes to docs/adr/0029-host-slot-health.md
@@ -0,0 +1,63 @@
1# Owned host slot health
2
3Status: implementation in progress for #42. Physical deployment remains disabled.
4
5The metadata store and candidate archive authenticate bytes without observing a
6runtime. Add host slot roles and health confirmation to that same exclusive
7protocol operation. Keep the immutable enrollment and pinned store identity.
8Format3 extends format2 atomically with host slot records, one installed role,
9one known-good role and bounded ordered command/status inputs. The writer file
10remains format1. The [bounded protocol SQLite decision](0028-runtime-trust-store.md)
11still applies; these records are not application CRUD.
12
13The host trial requires `host-neutral-bench-v1`, an explicit trusted local source
14checkout at the signed Git revision, and a clean firmware tree. Build and CTest
15that source, then require exact byte equality with the currently authenticated
16candidate. A signed profile declaration alone cannot reach runtime execution.
17Retain a separate private, non-executable slot image. Observe the corresponding
18byte-identical local host build. The source checkout, compiler and host OS remain
19trusted inputs; this is not a general executable attestation scheme.
20
21Commit selection before image IO. After copied-image readback, record the running
22host role while retaining the previous known-good role. Allocate boot and session
23challenges within the actual supervisor. Commit every bounded command/status input
24before interpreting health. Require progressing samples, the exact 100-tick neutral
25trial, the matching stop reply, clean process exit and complete allowed output.
26Recheck executable/slot bytes and the independently supplied clock. Commit the
27final time observation and its floor before checking expiration, so a rejected
28confirmation cannot discard trusted time. Check metadata expiration with local
29elapsed time and a one-second sample freshness limit before atomically recording confirmation, the new known-good role,
30the completed clock floor and release of the candidate reservation. No caller
31boolean, typed receipt or existing marker can perform that transition.
32
33The Linux transport regression uses a native C++17 fixture with the existing
34wire codec. It reports kernel FIFO capacity and queued bytes without consuming
35stdin, filling a complete kernel page before testing blocked-write cleanup.
36The test supports 4 KiB, 16 KiB and 64 KiB pages. It is host transport evidence;
37physical UART, board timing and electrical faults have separate acceptance gates.
38
39Reservation lifetime and slot lifetime are separate. A confirmed candidate's
40reservation becomes retired so a new candidate can be prepared. Its candidate
41history and files remain, and the separately confirmed slot role retains its own
42image and transcript. Historical confirmed records are not fresh current-policy
43authorization. Slot inspection rechecks record/byte integrity and the whole
44retained host transcript; it does not grant runtime authority.
45
46Metadata recovery preserves its verified prefix and security floors. An unfinished
47selected/running trial needs explicit abandonment by exact ID after that recovery.
48Abandonment retains files, inputs and the prior known-good role. A real completed
49confirmation transaction survives a crash before the enclosing attempt returns;
50recovery preserves that observed result without inventing another confirmation.
51
52Rollback selects the retained known-good identity under the same writer lease,
53checks its retained image, performs fresh current-time signed verification, and
54observes another actual runtime session. It cannot reuse the old health record as
55new health. Expired, revoked, below-floor, missing or corrupt images fail closed.
56The fixed policy target must currently authorize the exact image; older cached
57metadata cannot override a replacement target or the global release floor.
58
59These are host virtual-clock and local-file assertions. Actual board image/build
60attestation, bootloader installation, electrical power-loss recovery, secure boot,
61hardware anti-rollback, physical timing and commissioning still need their own
62implementation and measurements under #2/#9. Authenticated key repair and the full
63short/zero-write, ENOSPC/EDQUOT/EIO and sync-failure campaign remain required by #42.
docs/diagnostics.mdmodified+6-5
Changes to docs/diagnostics.md
@@ -159,11 +159,12 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
159159 TUF roles, declared compatibility and actual image bytes. Independently pinned
160160 enrollment/store records retain metadata and clock/release floors; historical
161161 recovery retains floors only. Owned candidate reservation/status/retirement retain
162 exact identities and files without granting boot authority. Complete key revocation/repair, installed and
163 known-good slot coordination, current-policy rollback and exact-image boot/session
164 health before flashing. Standalone staging/inspection still need explicit trust
165 and are separate from protected-current-policy selection. Host preparation does
166 not satisfy full update/rollback or hardware anti-rollback acceptance.
162 exact identities and files without granting boot authority. Host slots now add
163 actual owned-image session confirmation and fresh-policy rollback. Complete key
164 repair, full write/sync/storage-fault coverage and board image/install/boot/health
165 qualification before flashing. Standalone staging/inspection still need explicit
166 trust and remain separate from protected-current-policy selection. Host evidence
167 does not satisfy physical update/rollback or hardware anti-rollback acceptance.
167168- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
168169 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
169170
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors, actual image preparation and durable candidate ownership](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, installed/known-good slots and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/roadmap/issues.jsonmodified+14-3
Changes to docs/roadmap/issues.json
@@ -19,7 +19,8 @@
1919 "firmware/runtime/",
2020 "resources/functions/drone/runtime/",
2121 "docs/runtime.md",
22 "tests/unit/runtime.test.ts"
22 "tests/unit/runtime.test.ts",
23 "tests/fixtures/runtime/full-input.cpp"
2324 ],
2425 "remaining": [
2526 "Physical Pico 2 boot/flash evidence",
@@ -273,16 +274,26 @@
273274 "app/Commands/UpdatesReserve.ts",
274275 "app/Commands/UpdatesCandidates.ts",
275276 "app/Commands/UpdatesRetire.ts",
277 "app/Commands/UpdatesTrial.ts",
278 "app/Commands/UpdatesSlots.ts",
279 "app/Commands/UpdatesRollback.ts",
280 "app/Commands/UpdatesAbandon.ts",
281 "docs/adr/0029-host-slot-health.md",
276282 "docs/runtime-updates.md",
277283 "tests/unit/updates-preparation.test.ts",
278284 "tests/unit/updates-candidates.test.ts",
279 "tests/unit/updates-candidates-cli.test.ts"
285 "tests/unit/updates-candidates-cli.test.ts",
286 "tests/unit/updates-host-slots.test.ts",
287 "tests/unit/updates-host-policy.test.ts",
288 "tests/unit/updates-host-slots-cli.test.ts",
289 "tests/fixtures/updates/qualify-host-slots.ts",
290 "tests/fixtures/updates/host-slot-interruptions.ts"
280291 ],
281292 "remaining": [
282293 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
283294 "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence",
284295 "Measured board logging deadlines, memory, rotation and long-mission retention",
285 "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health",
296 "Complete coordinator key/clock maintenance, storage-fault recovery and board image/install/boot health and rollback qualification",
286297 "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence"
287298 ]
288299 },
docs/runtime-protocol.mdmodified+10-0
Changes to docs/runtime-protocol.md
@@ -92,6 +92,16 @@ requests, bounds response lines and times out unresponsive processes. Every retu
9292match the supervisor's launch nonce. Invalid responses kill the process and permanently fail
9393that transport. Returned status frames are immutable. Closing waits for actual process exit and
9494returns its exit code and signal; repeated closes retain the same result.
95Successful close also requires stdout completion without extra bytes and empty
96stderr. The one-second close deadline rejects inherited pipes that remain open
97after parent exit, cancels readers and retains cleanup failures. These checks
98support the [owned host slot trial](runtime-updates.md#owned-host-slots-and-observed-health).
99Host stdin uses an owned nonblocking POSIX pipe. The supervisor creates it with
100`mkfifo`, opens and verifies its descriptors, then unlinks its private directory
101before launching the process. Writes handle partial progress and EAGAIN within the
102existing two-second response deadline. Closing the writer drops the owned descriptor
103without waiting for a buffered flush. The deadline also cancels an inherited output
104read; killing an already-exited parent alone cannot finish that read.
95105
96106The optional [DS sensor stream](sensors.md) shares UART TX as complete 128-byte frames.
97107DR remains 64 bytes. Awaited DR replies take priority when the current frame finishes; partial
docs/runtime-updates.mdmodified+137-28
Changes to docs/runtime-updates.md
@@ -2,10 +2,11 @@
22
33Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
44staging, independent candidate verification, protected initial trust enrollment
5and a persistent host metadata protocol with protected image preparation. These
6commands prepare and verify host state and actual candidate image bytes. Key
7revocation/repair, installed images, boot selection and known-good rollback slots
8remain part of the full coordinator. Physical deployment is disabled.
5and a persistent host metadata protocol with protected image preparation. The host
6coordinator also retains image slots and observes owned neutral runtime health for
7confirmation and fresh-policy rollback. Key repair, complete storage-fault coverage
8and board installation/boot recovery remain part of the full coordinator. Physical
9deployment is disabled.
910
1011## Explicit trust and policy
1112
@@ -80,8 +81,9 @@ writes cache files in place; a host write-limit probe truncated its cached root.
8081Never refresh against the enrollment directory. Staging/inspection still require
8182their explicit policy and do not load or advance this enrollment automatically.
8283The metadata-store functions below retain verified metadata/release floors.
83The complete key lifecycle, storage-fault campaigns, image integration and
84known-good coordinator remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
84Authenticated key repair, complete storage-fault campaigns and board image/install/boot
85qualification remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
86The host coordinator below adds protected image preparation and observed known-good slots.
8587Do not discard or re-enroll over failed state to reset a security floor.
8688
8789## Persistent host metadata protocol
@@ -325,15 +327,104 @@ marker completion, prepared-record commit and retirement commit. Each cut observ
325327an actual competing process rejected with SQLITE_BUSY, then restarts, checks
326328unchanged candidate phase/floors and prepares a distinct fresh ID after explicit
327329recovery/retirement. This does not cover every SQLite/filesystem write or sync,
328ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. Installed
329and known-good slot roles, identity-bound runtime health, current-policy selection
330and rollback, authenticated key maintenance and the full fault campaign remain
331required by #42.
330ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. The host slot
331operation below adds actual image/session confirmation. Authenticated key repair,
332the full storage-fault campaign and physical board acceptance remain required by #42.
332333
333The [host bench](runtime.md) now binds each process to a fresh supervisor-owned boot nonce
334and requires a clean exit. This prerequisite provides no installed image identity, bounded
335health transcript or known-good confirmation. Slot health must still bind the verified image,
336owned boot/session challenge and actual runtime evidence before changing known-good state.
334## Owned host slots and observed health
335
336Host trials support `host-neutral-bench-v1`. Supply a trusted local source checkout
337whose HEAD is the signed `sourceRevision` and whose `firmware/runtime` tree is clean.
338The coordinator builds that source and runs CTest. Candidate bytes must exactly
339match the resulting neutral host executable before it can run. A signed text
340payload or a profile label cannot substitute for this equality check. The checkout,
341compiler and host OS remain trusted inputs.
342
343```sh
344./buddy updates:trial /private/runtime-store \
345 --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \
346 --store-sha256 "$STORE_SHA256" --candidate "$CANDIDATE_ID" \
347 --repository /private/runtime-repository --project /trusted/drone-source \
348 --trusted-system-clock-source operator-managed-clock
349
350./buddy updates:slots /private/runtime-store \
351 --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \
352 --store-sha256 "$STORE_SHA256"
353
354./buddy updates:rollback /private/runtime-store \
355 --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \
356 --store-sha256 "$STORE_SHA256" --repository /private/runtime-repository \
357 --project /trusted/drone-source --trusted-system-clock-source operator-managed-clock
358```
359
360The same native commands are registered in the standalone CLI. Clock-source names
361are audit labels for independently managed clocks, not clock attestations. The
362trusted clock is sampled before selection and again before confirmation. The final
363observation is committed to the retained clock floor before expiration checks, even
364if confirmation is later rejected. `clockCheckedMs` and `confirmedClockMs` distinguish
365an accepted time observation from a completed confirmation. Release floors cannot decrease.
366
367Format3 adds `host_slots`, `host_roles` and `host_inputs` in one transaction over
368the exact authenticated format2 schema. Existing identity, metadata, attempts,
369epochs and release/clock floors remain. Readers inspect format1/2 without upgrading
370them. Host trials upgrade only an owned format2 store. Older binaries reject the
371new format; retain state and use a supported reader instead of downgrading or
372reinitializing it. The writer database remains format1.
373
374Selection records an immutable slot ID before image IO. Each slot retains its own
375private image copy; the observed process executes the byte-identical local build.
376The `installed` host role records the copied image selected for that trial. It
377does not describe board installation. A failed trial can leave this role `failed`
378while the previous `knownGood` role and image remain intact.
379
380The supervisor owns fresh boot/session challenges and records 214 ordered inputs
381for its fixed 100-tick virtual-clock trial. Each input is committed before health
382interpretation. The final stop response must match the owned command, and success
383requires actual clean process exit, allowed stdout completion and empty stderr.
384Executable and copied-image bytes are rechecked before confirmation. A second
385trusted-clock sample must remain valid, and all active metadata must still be
386unexpired after accounting for local elapsed time. The interval after that sample
387has a one-second freshness guard. Confirmation, the known-good role, the completion clock floor and release
388of that candidate reservation commit atomically.
389
390Candidate retirement releases its reservation and retains its files/history.
391Separately confirmed slots retain their own images and transcripts. Thus a new
392candidate can be prepared without overwriting the prior known-good image. Slot
393inspection verifies retained image integrity and the full boot/session-bound
394transcript. Its `currentTrustValidated: false` means historical health does not
395authorize use under current policy. A corrupt or missing retained rollback image
396is actionable even when another archive contains matching bytes.
397
398Rollback performs fresh signed verification under current roots, metadata and
399security floors, then observes another actual host process with new challenges.
400The fixed target must currently authorize those exact bytes. A newer signed target
401or targets-key revocation can make the retained known-good image unavailable for
402rollback. Metadata/release advancement survives that rejection. Missing, corrupt,
403expired or below-floor images cannot silently repair themselves or reuse old health.
404
405After an interrupted trial, run `updates:recover` to resolve metadata first. Inspect
406the retained trial with `updates:slots`. A `selected` or `running` trial then needs
407deliberate abandonment by its exact ID:
408
409```sh
410./buddy updates:abandon /private/runtime-store \
411 --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \
412 --store-sha256 "$STORE_SHA256" --trial "$TRIAL_ID"
413```
414
415Abandonment retains files, inputs and known-good state. Metadata replay cannot
416promote an unfinished trial. A confirmation transaction that actually completed
417before interruption survives replay, including when its enclosing attempt had
418not yet returned. History is bounded to 128 slots, 256 inputs per trial and the
419existing global database/attempt limits. Exhaustion requires supported maintenance,
420never deletion of trust history or reset of a security floor.
421
422Host tests cut actual processes after selection, image readback, running-state
423commit, mid-session input commit, final trusted-clock commit and health confirmation. Each cut checks a real
424competing writer, restart behavior and fresh rollback. These are process-crash and
425host-file observations. Board boot, electrical power-loss durability, syscall/storage
426fault campaigns, secure boot and hardware anti-rollback require separate evidence.
427See the [host slot decision](adr/0029-host-slot-health.md).
337428
338429## Repository and signed image contract
339430
@@ -400,8 +491,8 @@ pre-delivery replay marker and completed domain-policy rejection.
400491The seam alone does not persist a protected prefix or make SDK cache writes
401492transactional. Its implementation and tests are a prerequisite for the protected
402493store and recovery coordinator in issue #42. The bounded metadata protocol above
403uses that seam; neither completes the full coordinator or
404permit flashing, boot selection or physical deployment.
494uses that seam. Flashing, physical boot selection and deployment remain disabled
495pending the full coordinator.
405496
406497The selected target must have SHA-256, a nonzero length of at most 16 MiB and
407498exactly these fields in its signed `custom.drone` object:
@@ -422,8 +513,10 @@ paths use bounded ASCII names without traversal, escapes or symlinks. Metadata
422513is limited to 512 KiB per file, 16 root rotations, 16 delegations, 64 requests and
42351432 MiB of distinct fetched bytes. FIFO, device, symlink and oversized inputs fail.
424515The neutral profile is a **signer's declaration**, not an inspection of machine
425code or proof of neutral outputs. This module authenticates bytes and declared
426compatibility; it does not parse UF2/ELF or execute an image.
516code or proof of neutral outputs. Standalone staging and inspection authenticate
517bytes and declared compatibility without parsing UF2/ELF or executing an image.
518The host trial above additionally requires equality with the actual trusted local
519neutral build and observes its process before confirming a known-good host slot.
427520
428521## Candidate integrity and interruption
429522
@@ -451,24 +544,40 @@ treat marker existence alone as permission to install or boot it.
451544The separate image-stage/inspection path still starts from its supplied root.
452545Its `trustStatePersisted: false` result means it does not use the metadata store's
453546cross-invocation floors. The caller must supply its trusted release floor each
454time. Neither path is a complete anti-rollback coordinator. Trusted clock
455acquisition and protection against privileged clock/storage rollback remain
456open. Control-loop freshness still uses its separate monotonic clock.
547time. The host coordinator enforces its own retained floors. Trusted clock
548acquisition and protection against privileged clock/storage rollback remain open.
549Control-loop freshness still uses its separate monotonic clock.
457550
458551Initial enrollment, the bounded persistent metadata protocol, protected
459signed-image preparation and durable candidate ownership are available above.
460Complete authenticated maintenance/key revocation and the full storage-fault
461campaign, then installed/known-good slot integrity, explicit health
462confirmation and rollback without lowering security floors. Then add
463the board bootloader/install protocol and measured power-loss, full-medium,
552signed-image preparation, owned candidates and observed host slot health are
553available above. Complete authenticated key maintenance/repair and the full
554revocation/storage-fault campaigns. Add the board image/build attestation,
555bootloader/install protocol and measured power-loss, full-medium,
464556storage-latency and recovery campaigns. Local privileged filesystem tampering
465557is outside this host module's protection; it is not secure boot or hardware
466558anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.github.io/specification/latest/).
467559
468Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes.
469They cover real role signatures/rotation, expiration, compatibility, supplied
560Tests use generated throwaway Ed25519 keys. Staging cases use clearly synthetic
561payload bytes; host trials use actual built neutral kernels and a deliberately
562compiled wrong-session stop variant. Cases cover real role signatures/rotation,
563expiration, compatibility, supplied
470564release floors, tampering, bounded inputs, incomplete candidates, enrollment
471565readback, competing enrollment processes, an actual host write-limit failure and
472566native/portable CLI behavior. They establish host behavior, not actual firmware
473567installation, board boot recovery or commissioning. The diagnostic supervisor's
474568update/rollback check remains blocked and `commissioningReady` stays false.
569
570The copied host-slot CLI campaign uses actual signed kernel bytes and retains
57117 maintenance operations, an interrupted trial and six additional process-cut
572states. Exact-ID abandonment follows metadata recovery; fresh rollback uses a
573different owned boot/session. A missing retained image is rejected without
574reconstructing it from the candidate archive. An older format-2 binary rejects
575format 3 before changing either database. Separate source-policy cases reject a
576wrong signed revision and dirty firmware before runtime input, retaining the
577accepted release floor through recovery. Process cuts and host filesystem tests
578do not establish electrical power-loss behavior.
579
580An actual CMake output-overflow case also stops before runtime start, retains
581release floor 9 through recovery, then observes a fresh healthy trial after the
582private build fault is removed. Build tools use native bounded process capture
583with the existing 120-second command deadline and 2 MB per-stream limits.
docs/runtime.mdmodified+12-5
Changes to docs/runtime.md
@@ -20,9 +20,14 @@ sensors, estimation, controls and authority work in the linked roadmap.
2020
2121## Run the native host harness
2222
23Requires Bun, CMake >= 3.20, a C++17 compiler and CTest on macOS or Linux. There are no fetched SDK
23Requires Bun, CMake >= 3.20, a C++17 compiler, CTest and the POSIX `mkfifo` utility on macOS or Linux. There are no fetched SDK
2424inputs for host builds. From the checkout:
2525
26Each build/tool command has a 120-second deadline and a 2 MB limit per output
27stream. Native Stacks process capture drains stdout and stderr concurrently and
28cleans up the owned process group on POSIX before reporting failure. This also
29applies to SDK inspection and Pico 2 builds; it does not install firmware.
30
2631```sh
2732./buddy runtime:build
2833./buddy runtime:bench --ticks 1000
@@ -42,10 +47,12 @@ The same fixed-buffer UART state machine used by Pico is also exercised through
4247for fragmentation, resynchronization, corruption, RX/TX budgets and pending transmit buffers.
4348
4449Each host launch receives a fresh supervisor-owned boot nonce. The report includes its decimal
45`bootId`, and the harness checks that identity on every immutable response. The bench reports
46success only after its actual process exits cleanly. This is host session separation, without
47image attestation or update health confirmation. It does not measure board boot entropy or
48physical timing. See the [host startup contract](runtime-protocol.md#host-hal-test-channel).
50`bootId` and `sessionId`, and the harness checks boot identity on every immutable response.
51The report's `cleanProcessExit` requires an actual clean process exit. Fault experiments
52can finish with a clean OS exit and retain their diagnostic failure. The update coordinator
53adds [current signed-image checks and a fixed healthy host trial](runtime-updates.md#owned-host-slots-and-observed-health).
54Board boot entropy and physical timing still require measurement. See the
55[host startup contract](runtime-protocol.md#host-hal-test-channel).
4956
5057Build a standalone CLI for the current platform:
5158
resources/functions/artifacts/native-cli/PROVENANCE.mdadded+18-0
Changes to resources/functions/artifacts/native-cli/PROVENANCE.md
@@ -0,0 +1,18 @@
1# Explicit Stacks CLI source bridge
2
3`process.ts` is a byte-for-byte copy from Stacks commit
4[3c52a79013df2e8026e4b364dfb9589eaf11375c](https://github.com/stacksjs/stacks/blob/3c52a79013df2e8026e4b364dfb9589eaf11375c/storage/framework/core/cli/src/process.ts),
5under the upstream [MIT licence](https://github.com/stacksjs/stacks/blob/3c52a79013df2e8026e4b364dfb9589eaf11375c/LICENSE.md).
6SHA-256: `33112ca2c6f9e576ec758d94108473ac522e17d9958067de0a9a932b5e5d4174`.
7
8Drone's installed `@stacksjs/cli` 0.75.221 does not expose this module. No
9installed package, dependency range or lockfile is changed. Replace this explicit
10bridge with `@stacksjs/cli/process` after a packaged release is qualified here.
11The source contribution is tracked in Stacks PRs #2970 and #2972; their main and
12CI states are retained separately in the implementation evidence.
13
14The app facade retains the existing `runBoundedHostProcess` name. Git inspection,
15parity tooling, portable qualification and local build commands use the native
16finite process implementation. Callers retain their own trust, path, output and
17time policies. Process cleanup does not establish signed-image authority, board
18health, hardware installation or physical commissioning.
scripts/parity-process.ts resources/functions/artifacts/native-cli/process.tscopied+35-15
Changes to resources/functions/artifacts/native-cli/process.ts
@@ -1,4 +1,4 @@
1export interface ParityProcessOptions {
1export interface BoundedProcessOptions {
22 cwd?: string
33 env?: Record<string, string | undefined>
44 timeoutMs: number
@@ -7,27 +7,38 @@ export interface ParityProcessOptions {
77 label: string
88}
99
10export interface BoundedProcessResult {
11 exitCode: number
12 stdout: string
13 stderr: string
14}
15
1016/** Owns a finite command, its output readers and, on POSIX, its process group. */
11export async function runParityProcess(command: string[], options: ParityProcessOptions) {
12 if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 60000) throw new Error('Invalid parity process timeout')
17export async function runBoundedProcess(command: string[], options: BoundedProcessOptions): Promise<BoundedProcessResult> {
18 if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 120000) throw new Error('Invalid bounded process timeout')
1319 for (const limit of [options.stdoutBytes, options.stderrBytes]) {
14 if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid parity process output limit')
20 if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid bounded process output limit')
1521 }
1622 const ownsGroup = process.platform !== 'win32'
1723 const child = Bun.spawn(command, { cwd: options.cwd, env: options.env ?? process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: ownsGroup })
1824 const readers = [child.stdout.getReader(), child.stderr.getReader()]
1925 const cancellations: Promise<unknown>[] = []
20 let failure: Error | undefined, stopped = false
26 let failure: unknown, failed = false, stopped = false
27 let failureReason = 'failed'
28 const cleanupFailures: unknown[] = []
29 function retainCleanup(error: unknown) {
30 if ((error as NodeJS.ErrnoException)?.code !== 'ESRCH') cleanupFailures.push(error)
31 }
2132 function terminate() {
22 if (ownsGroup) { try { process.kill(-child.pid, 'SIGKILL') } catch { /* The group may already be gone. */ } }
23 try { child.kill('SIGKILL') } catch { /* The direct child may already have exited. */ }
33 if (ownsGroup) { try { process.kill(-child.pid, 'SIGKILL') } catch (error) { retainCleanup(error) } }
34 if (child.exitCode === null) { try { child.kill('SIGKILL') } catch (error) { retainCleanup(error) } }
2435 }
25 function stop(error: Error) {
26 failure ??= error
36 function stop(error: unknown, reason = 'failed') {
37 if (!failed) { failure = error; failed = true; failureReason = reason }
2738 if (stopped) return
2839 stopped = true
2940 terminate()
30 for (const reader of readers) cancellations.push(reader.cancel().catch(() => {}))
41 for (const reader of readers) cancellations.push(reader.cancel().catch(error => { cleanupFailures.push(error) }))
3142 }
3243 async function consume(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number, label: string) {
3344 const chunks: Uint8Array[] = []
@@ -36,25 +47,34 @@ export async function runParityProcess(command: string[], options: ParityProcess
3647 while (true) {
3748 const { done, value } = await reader.read()
3849 if (done) return Buffer.concat(chunks, bytes).toString('utf8')
39 if (value.byteLength > limit - bytes) throw new Error(`${options.label} ${label} exceeds byte limit`)
50 if (value.byteLength > limit - bytes) {
51 const error = new Error(`${options.label} ${label} exceeds byte limit`)
52 stop(error, `${label} exceeds byte limit`)
53 throw error
54 }
4055 bytes += value.byteLength
4156 chunks.push(value)
4257 }
4358 }
44 catch (error) { stop(error instanceof Error ? error : new Error(String(error))); throw error }
59 catch (error) { stop(error, `${label} read failed`); throw error }
4560 }
46 const timer = setTimeout(() => stop(new Error(`${options.label} timed out`)), options.timeoutMs)
61 const timer = setTimeout(() => stop(new Error(`${options.label} timed out`), 'timed out'), options.timeoutMs)
4762 const stdout = consume(readers[0]!, options.stdoutBytes, 'stdout'), stderr = consume(readers[1]!, options.stderrBytes, 'stderr')
4863 try {
4964 const [exitCode, output, diagnostic] = await Promise.all([child.exited, stdout, stderr])
50 if (failure) throw failure
65 if (failed) throw failure
5166 return { exitCode, stdout: output, stderr: diagnostic }
5267 }
68 catch (error) { stop(error); throw error }
5369 finally {
5470 clearTimeout(timer)
5571 terminate()
5672 if (child.exitCode === null) stop(new Error(`${options.label} interrupted`))
5773 await Promise.allSettled([child.exited, stdout, stderr, ...cancellations])
58 for (const reader of readers) reader.releaseLock()
74 for (const reader of readers) {
75 try { reader.releaseLock() }
76 catch (error) { cleanupFailures.push(error) }
77 }
78 if (cleanupFailures.length) throw new AggregateError(failed ? [failure, ...cleanupFailures] : cleanupFailures, `${options.label} ${failed ? `${failureReason}; ` : ''}process cleanup failed`)
5979 }
6080}
resources/functions/drone/runtime/bench.tsmodified+1-0
Changes to resources/functions/drone/runtime/bench.ts
@@ -64,6 +64,7 @@ export async function runRuntimeBench(options: { project?: string, ticks?: numbe
6464 return {
6565 evidence: 'host-harness', boardCandidate: 'pico2', protocolVersion: 1, fault,
6666 bootId: String(harness.bootId),
67 sessionId: String(status.sessionId), cleanProcessExit: true,
6768 ticks: status.ticks, virtualElapsedUs: time, hostElapsedMs: Math.round((performance.now() - started) * 1000) / 1000,
6869 stateBytes: status.stateBytes, maxExecutionUs: status.maxExecutionUs, deadlineMisses: status.deadlineMisses,
6970 neutralOnly: true, leaseActive: false, physicalFlightEnabled: false,
resources/functions/drone/runtime/build.tsmodified+4-8
Changes to resources/functions/drone/runtime/build.ts
@@ -1,16 +1,12 @@
11import { resolve, join } from 'node:path'
2import { runBoundedHostProcess } from '../../host/process'
23
34export const picoSdkRevision = '079c6f39023649b154152db30f1d781e884879bc'
45export interface RuntimeBuildOptions { project?: string, target?: 'host' | 'pico2', sdk?: string, toolchain?: string, watchdogTest?: boolean, sensors?: boolean }
56async function run(args: string[], cwd: string, env = process.env) {
6 const child = Bun.spawn(args, { cwd, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
7 const timeout = setTimeout(() => child.kill('SIGKILL'), 120000)
8 try {
9 const [output, errors, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
10 if (code !== 0) throw new Error(`${args[0]} exited ${code}:\n${(output + errors).slice(-8000)}`)
11 return output.trim()
12 }
13 finally { clearTimeout(timeout) }
7 const result = await runBoundedHostProcess(args, { cwd, env, timeoutMs: 120000, stdoutBytes: 2_000_000, stderrBytes: 2_000_000, label: `Runtime build ${args[0]}` })
8 if (result.exitCode !== 0) throw new Error(`${args[0]} exited ${result.exitCode}:\n${(result.stdout + result.stderr).slice(-8000)}`, { cause: result })
9 return result.stdout.trim()
1410}
1511/** Local tools only; no shell interpolation, SDK download, firmware flashing or device access. */
1612export async function buildRuntime(options: RuntimeBuildOptions = {}) {
resources/functions/drone/runtime/harness.tsmodified+185-16
Changes to resources/functions/drone/runtime/harness.ts
@@ -1,5 +1,9 @@
11import { decodeRuntimeFrame } from './protocol'
22import { randomBytes } from 'node:crypto'
3import { spawnSync } from 'node:child_process'
4import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, rmSync, writeSync } from 'node:fs'
5import { tmpdir } from 'node:os'
6import { join } from 'node:path'
37
48function freshBootId() {
59 let value = 0n
@@ -8,23 +12,130 @@ function freshBootId() {
812}
913export interface RuntimeHarnessExit { exitCode: number, signal: string | number | null }
1014
15/** Own both descriptors before unlinking the private FIFO. No buffered async writer survives a deadline. */
16function runtimeInputPipe() {
17 const root = mkdtempSync(join(tmpdir(), 'drone-runtime-input-')), path = join(root, 'stdin')
18 let staged = -1, reader = -1, writer = -1
19 try {
20 const result = spawnSync('mkfifo', ['-m', '600', path], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 1000 })
21 if (result.error) throw result.error
22 if (result.status !== 0) throw new Error('Runtime input pipe creation failed', { cause: { exitCode: result.status, signal: result.signal, stderr: result.stderr } })
23 const before = lstatSync(path)
24 if (!before.isFIFO() || before.isSymbolicLink() || before.uid !== process.getuid?.() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600) throw new Error('Invalid runtime input pipe')
25 staged = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
26 writer = openSync(path, constants.O_WRONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
27 reader = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
28 for (const fd of [staged, writer, reader]) {
29 const stat = fstatSync(fd)
30 if (!stat.isFIFO() || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Runtime input pipe changed while opening')
31 }
32 closeSync(staged); staged = -1
33 rmSync(root, { recursive: true })
34 return { reader, writer }
35 }
36 catch (error) {
37 const failures = [error]
38 for (const fd of [staged, reader, writer]) if (fd >= 0) {
39 try { closeSync(fd) }
40 catch (cleanup) { failures.push(cleanup) }
41 }
42 try { rmSync(root, { recursive: true, force: true }) }
43 catch (cleanup) { failures.push(cleanup) }
44 if (failures.length > 1) throw new AggregateError(failures, 'Runtime input pipe creation and cleanup failed')
45 throw error
46 }
47}
48
1149/** ASCII stepping exists only in the host HAL. Board UART carries raw 64-byte frames. */
1250export function openRuntimeHarness(artifact: string) {
1351 const bootId = freshBootId()
14 const child = Bun.spawn([artifact, '--stdio', '--boot-id', bootId.toString(16).padStart(16, '0')], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
52 const input = runtimeInputPipe()
53 let child: Bun.Subprocess<number, 'pipe', 'pipe'>
54 try { child = Bun.spawn([artifact, '--stdio', '--boot-id', bootId.toString(16).padStart(16, '0')], { stdin: input.reader, stdout: 'pipe', stderr: 'pipe' }) }
55 catch (error) {
56 const failures = [error]
57 for (const fd of [input.reader, input.writer]) {
58 try { closeSync(fd) }
59 catch (cleanup) { failures.push(cleanup) }
60 }
61 if (failures.length > 1) throw new AggregateError(failures, 'Native harness spawn and input cleanup failed')
62 throw error
63 }
64 try { closeSync(input.reader) }
65 catch (error) {
66 child.kill('SIGKILL')
67 try { closeSync(input.writer) }
68 catch (cleanup) { throw new AggregateError([error, cleanup], 'Native harness reader close and input cleanup failed') }
69 throw error
70 }
71 let inputFd = input.writer
1572 const reader = child.stdout.getReader()
73 const errors = child.stderr.getReader()
1674 let pending = ''
1775 let active = false
1876 let closed = false
1977 let failed = false
2078 let closing: Promise<Readonly<RuntimeHarnessExit>> | undefined
79 let activeCompletion = Promise.resolve()
80 let stderrFinished = false
81 const inputFailures: unknown[] = []
82 function stopInput() {
83 if (inputFd < 0) return
84 const fd = inputFd; inputFd = -1
85 try { closeSync(fd) }
86 catch (error) { inputFailures.push(error) }
87 }
88 const exited = child.exited.then(exitCode => ({ ok: true, value: Object.freeze({ exitCode, signal: child.signalCode ?? null }) } as const), error => ({ ok: false, error } as const))
89 const stderrCompletion = (async () => {
90 try {
91 while (true) {
92 const { value, done } = await errors.read()
93 if (done) return
94 if (value.length) throw new Error('Native harness produced stderr')
95 }
96 }
97 finally {
98 try { await errors.cancel() }
99 finally { errors.releaseLock() }
100 }
101 })().then(() => { stderrFinished = true; return { ok: true } as const }, error => {
102 stderrFinished = true
103 failed = true
104 child.kill('SIGKILL')
105 return { ok: false, error } as const
106 })
21107 async function exchange(line: string) {
22108 if (closed || failed || active || line.length > 150 || line.includes('\n')) throw new Error('Invalid, failed or concurrent harness request')
23109 active = true
24 const timeout = setTimeout(() => child.kill('SIGKILL'), 2000)
25 try {
26 child.stdin.write(`${line}\n`)
27 await child.stdin.flush()
110 let finish!: () => void
111 activeCompletion = new Promise<void>(resolve => { finish = resolve })
112 let timeout: ReturnType<typeof setTimeout>, deadlineHit = false
113 let responseCancellation: Promise<{ ok: true } | { ok: false, error: unknown }> | undefined
114 const deadlineError = new Error('Native harness response deadline exceeded')
115 const deadline = new Promise<{ ok: false, error: Error }>(resolve => {
116 timeout = setTimeout(() => {
117 deadlineHit = true; failed = true
118 child.kill('SIGKILL'); stopInput()
119 resolve({ ok: false, error: deadlineError })
120 responseCancellation = reader.cancel().then(() => ({ ok: true } as const), error => ({ ok: false, error } as const))
121 }, 2000)
122 })
123 const completion = (async () => {
124 const bytes = Buffer.from(`${line}\n`)
125 let offset = 0
126 while (offset < bytes.length) {
127 if (inputFd < 0 || failed) throw new Error('Native harness input closed before delivery')
128 try {
129 const written = writeSync(inputFd, bytes, offset, bytes.length - offset)
130 if (written <= 0) throw new Error('Native harness input made no progress')
131 offset += written
132 }
133 catch (error) {
134 const code = (error as NodeJS.ErrnoException).code
135 if (code !== 'EAGAIN' && code !== 'EWOULDBLOCK') throw error
136 await Bun.sleep(1)
137 }
138 }
28139 while (!pending.includes('\n')) {
29140 const { value, done } = await reader.read()
30141 if (done) throw new Error('Native harness exited without a response')
@@ -38,9 +149,27 @@ export function openRuntimeHarness(artifact: string) {
38149 const frame = decodeRuntimeFrame(Uint8Array.fromHex(hex))
39150 if (frame.kind !== 'status' || frame.bootId !== bootId) throw new Error('Native harness boot identity rejected')
40151 return Object.freeze(frame)
152 })().then(value => ({ ok: true, value } as const), error => ({ ok: false, error } as const))
153 try {
154 const outcome = await Promise.race([completion, deadline])
155 if (deadlineHit) {
156 const stopped = await completion
157 const cancellation = await responseCancellation
158 const failures: unknown[] = [deadlineError]
159 if (!stopped.ok && stopped.error !== deadlineError) failures.push(stopped.error)
160 if (cancellation && !cancellation.ok) failures.push(cancellation.error)
161 if (failures.length > 1) throw new AggregateError(failures, 'Native harness response deadline exceeded')
162 throw deadlineError
163 }
164 if (!outcome.ok) throw outcome.error
165 return outcome.value
166 }
167 catch (error) { failed = true; child.kill('SIGKILL'); stopInput(); throw error }
168 finally {
169 active = false; clearTimeout(timeout!)
170 if (responseCancellation) await responseCancellation
171 finish()
41172 }
42 catch (error) { failed = true; child.kill('SIGKILL'); throw error }
43 finally { active = false; clearTimeout(timeout) }
44173 }
45174 return {
46175 get bootId() { return bootId },
@@ -50,20 +179,60 @@ export function openRuntimeHarness(artifact: string) {
50179 closed = true
51180 if (active) child.kill('SIGKILL')
52181 closing = (async () => {
53 let inputFailed = false, inputError: unknown
54 try { child.stdin.end() }
55 catch (error) { inputFailed = true; inputError = error; child.kill('SIGKILL') }
56 const timeout = setTimeout(() => child.kill('SIGKILL'), 1000)
182 const failures: unknown[] = []
183 const retain = (error: unknown) => { if (!failures.includes(error)) failures.push(error) }
184 let timeout: ReturnType<typeof setTimeout>, deadlineHit = false
185 const deadline = new Promise<never>((_, reject) => {
186 timeout = setTimeout(() => {
187 failed = true
188 deadlineHit = true
189 child.kill('SIGKILL'); stopInput()
190 reject(new Error('Native harness close deadline exceeded'))
191 }, 1000)
192 })
193 const completion = (async () => {
194 await activeCompletion
195 stopInput()
196 const exit = await exited
197 if (!exit.ok) throw exit.error
198 if (inputFailures.length) throw inputFailures[0]
199 const stderr = await stderrCompletion
200 if (!stderr.ok) throw stderr.error
201 const tail = await reader.read()
202 if (pending.length || !tail.done) throw new Error('Native harness produced unexpected output while closing')
203 return exit.value
204 })().then(value => ({ ok: true, value } as const), error => ({ ok: false, error } as const))
205 let result: Readonly<RuntimeHarnessExit> | undefined
57206 try {
58 const exitCode = await child.exited
59 if (inputFailed) throw inputError
60 return Object.freeze({ exitCode, signal: child.signalCode ?? null })
207 const outcome = await Promise.race([completion, deadline])
208 if (outcome.ok) result = outcome.value
209 else retain(outcome.error)
61210 }
211 catch (error) { retain(error) }
62212 finally {
63 clearTimeout(timeout)
213 clearTimeout(timeout!)
214 if (failures.length) { failed = true; child.kill('SIGKILL'); stopInput() }
64215 try { await reader.cancel() }
65 finally { reader.releaseLock() }
216 catch (error) { retain(error) }
217 if (!stderrFinished) {
218 try { await errors.cancel() }
219 catch (error) { retain(error) }
220 }
221 await activeCompletion
222 const outcome = await completion
223 if (!outcome.ok) retain(outcome.error)
224 const stderr = await stderrCompletion
225 if (!stderr.ok) retain(stderr.error)
226 const exit = await exited
227 if (!exit.ok) retain(exit.error)
228 for (const error of inputFailures) retain(error)
229 try { reader.releaseLock() }
230 catch (error) { retain(error) }
66231 }
232 if (failures.length > 1) throw new AggregateError(failures, deadlineHit ? 'Native harness close deadline exceeded; cleanup failed' : 'Native harness close and cleanup failed')
233 if (failures.length) throw failures[0]
234 if (!result) throw new Error('Native harness close result unavailable')
235 return result
67236 })()
68237 return closing
69238 },
resources/functions/drone/updates/command.tsmodified+10-0
Changes to resources/functions/drone/updates/command.ts
@@ -19,6 +19,12 @@ export const storeEnrollmentFlags = {
1919 '--enrollment-sha256 <hex>': 'Independently retained enrollment record SHA-256',
2020} as const
2121export const storeReferenceFlags = { ...storeEnrollmentFlags, '--store-sha256 <hex>': 'Independently retained update-store identity SHA-256' } as const
22export const hostTrialFlags = {
23 ...storeReferenceFlags,
24 '--repository <path>': 'Bounded offline signed-image repository',
25 '--trusted-system-clock-source <name>': 'Audit label for the independently managed system clock, not an attestation',
26 '--project <path>': 'Explicit trusted clean local firmware source checkout matching the signed revision',
27} as const
2228
2329export function storeCommandEnrollment(options: { enrollment?: unknown, enrollmentSha256?: unknown }) {
2430 if (typeof options.enrollment !== 'string' || typeof options.enrollmentSha256 !== 'string') throw new Error('Explicit enrollment and independent enrollment pin required')
@@ -28,6 +34,10 @@ export function storeCommandReference(options: { enrollment?: unknown, enrollmen
2834 if (typeof options.storeSha256 !== 'string') throw new Error('Explicit independent store pin required')
2935 return { ...storeCommandEnrollment(options), directory, storeSha256: options.storeSha256 }
3036}
37export function hostTrialCommandOptions(options: { enrollment?: unknown, enrollmentSha256?: unknown, storeSha256?: unknown, repository?: unknown, trustedSystemClockSource?: unknown, project?: unknown }, directory: string) {
38 if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string' || typeof options.project !== 'string') throw new Error('Explicit repository, independent system-clock source and trusted project required')
39 return { ...storeCommandReference(options, directory), repository: options.repository, project: options.project, clock: { source: options.trustedSystemClockSource, read: () => new Date() } }
40}
3141export async function storeCommandFailure() {
3242 process.exitCode = 1
3343 try { await printErrorAsync('Runtime update store unavailable: invalid trust, clock, state or storage failure') }
resources/functions/drone/updates/host-evidence.tsadded+38-0
Changes to resources/functions/drone/updates/host-evidence.ts
@@ -0,0 +1,38 @@
1import { commandFrame, decodeRuntimeFrame, encodeRuntimeFrame } from '../runtime/protocol'
2
3interface HostEvidenceInput { kind: 'tx' | 'rx', bytes: Uint8Array }
4
5/** Validate retained host-only protocol evidence. This cannot confirm a new slot or attest a process exit. */
6export function verifyRuntimeHostTranscript(inputs: ReadonlyArray<HostEvidenceInput>, identity: { bootId: string, sessionId: string }) {
7 const boot = BigInt(identity.bootId), session = BigInt(identity.sessionId)
8 let index = 0, ticks = 1, time = 1000, expiry = 0, commandSequence = 1, stateBytes = 0
9 function pair(request: string, boundSession: bigint) {
10 const tx = inputs[index++], rx = inputs[index++]
11 if (!tx || !rx || tx.kind !== 'tx' || rx.kind !== 'rx' || !Buffer.from(tx.bytes).equals(Buffer.from(request))) throw new Error('Host health transcript request rejected')
12 let status
13 try { status = decodeRuntimeFrame(rx.bytes) }
14 catch (cause) { throw new Error('Host health transcript status rejected', { cause }) }
15 const lease = Math.max(expiry - time, 0)
16 if (!stateBytes) stateBytes = status.stateBytes
17 if (status.kind !== 'status' || status.sequence !== index / 2 || status.bootId !== boot || status.sessionId !== boundSession
18 || status.ticks !== ticks || status.sampleTimeUs !== BigInt(time) || status.sample !== ticks - 1000
19 || status.leaseUs !== lease || status.flags !== (67 | (lease ? 4 : 0)) || status.deadlineMisses !== 0
20 || status.maxExecutionUs !== 0 || stateBytes < 1 || stateBytes > 128 || status.stateBytes !== stateBytes) throw new Error('Host health transcript identity, progress or health rejected')
21 }
22 function frame(kind: 'hello' | 'lease' | 'stop') {
23 if (kind === 'lease') expiry = time + 100000
24 if (kind === 'stop') expiry = 0
25 const command = commandFrame(kind, commandSequence++, kind === 'hello' ? 0n : boot, session, kind === 'hello' ? 0n : BigInt(time), kind === 'lease' ? 100000 : 0)
26 pair(`frame ${Buffer.from(encodeRuntimeFrame(command)).toString('hex')}`, session)
27 }
28 pair('tick 1000', 0n)
29 frame('hello')
30 frame('lease')
31 for (let step = 1; step < 100; step++) {
32 ticks++; time += 1000
33 pair(`tick ${time}`, session)
34 if (step % 20 === 0) frame('lease')
35 }
36 frame('stop')
37 if (index !== inputs.length || index !== 214) throw new Error('Host health transcript length rejected')
38}
resources/functions/drone/updates/host-runtime.tsadded+95-0
Changes to resources/functions/drone/updates/host-runtime.ts
@@ -0,0 +1,95 @@
1import type { RuntimeFrame } from '../runtime/protocol'
2import type { RuntimeFirmwareStage } from './staging'
3import { realpathSync } from 'node:fs'
4import { join, relative, resolve, sep } from 'node:path'
5import { runRuntimeBench } from '../runtime/bench'
6import { decodeRuntimeFrame } from '../runtime/protocol'
7import { directory, imageLimit, regularFile, sha256, syncDirectory, writeExclusive } from './files'
8import { runBoundedHostProcess } from '../../host/process'
9
10/** Read-only preflight, including build paths whose final directories do not exist yet. */
11export function runtimeHostBuildProject(project: string, excluded: readonly string[]) {
12 const root = realpathSync(resolve(project))
13 directory(root)
14 const parts = ['storage', 'runtime-build', 'host']
15 let output = root
16 for (let index = 0; index < parts.length; index++) {
17 const next = join(output, parts[index]!)
18 try { directory(next); output = realpathSync(next) }
19 catch (error) {
20 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
21 output = join(output, ...parts.slice(index)); break
22 }
23 }
24 for (const reference of excluded) {
25 const protectedRoot = realpathSync(reference)
26 const child = relative(protectedRoot, output), ancestor = relative(output, protectedRoot)
27 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!ancestor.startsWith(`..${sep}`) && ancestor !== '..')) throw new Error('Host build output must be separate from repository and protected trust state')
28 }
29 return root
30}
31
32async function sourceCommand(project: string, args: string[]) {
33 const result = await runBoundedHostProcess(['git', '-C', project, ...args], { cwd: project, timeoutMs: 10000, stdoutBytes: 65536, stderrBytes: 65536, label: 'Host trial source inspection' })
34 if (result.exitCode !== 0 || result.stderr) throw new Error('Host trial source inspection failed', { cause: { exitCode: result.exitCode, stderr: result.stderr } })
35 return result.stdout.trim()
36}
37
38/** Internal coordinator operation: execute only bytes matching the actual owned neutral host build. */
39export async function observeRuntimeHostImage(options: {
40 project: string
41 description: Readonly<RuntimeFirmwareStage>
42 candidateImage: string
43 slotDirectory: string
44 protectedDirectories: readonly string[]
45 onReady: () => Promise<void>
46 onRequest: (line: string) => Promise<void>
47 onStatus: (status: Readonly<RuntimeFrame>) => Promise<void>
48}) {
49 const project = runtimeHostBuildProject(options.project, options.protectedDirectories), description = options.description
50 if (description.platform !== 'host-neutral-bench-v1') throw new Error('Only owned neutral host images support host trials')
51 const image = regularFile(options.candidateImage, imageLimit)
52 if (image.length !== description.length || sha256(image) !== description.sha256) throw new Error('Host trial candidate image changed')
53 async function checkSource() {
54 if (await sourceCommand(project, ['rev-parse', '--show-toplevel']) !== project
55 || await sourceCommand(project, ['rev-parse', 'HEAD']) !== description.sourceRevision
56 || await sourceCommand(project, ['status', '--porcelain=v1', '--untracked-files=all', '--', 'firmware/runtime'])) throw new Error('Host trial requires the signed clean firmware source revision')
57 }
58 await checkSource()
59 let artifact: string | undefined
60 let started = 0
61 let stop: RuntimeFrame | undefined, last: Readonly<RuntimeFrame> | undefined
62 const report = await runRuntimeBench({ project, ticks: 100, fault: 'none',
63 async onBuild(path) {
64 await checkSource()
65 const built = regularFile(path, imageLimit)
66 if (!built.equals(image)) throw new Error('Signed host image does not match the actual owned neutral build')
67 artifact = path
68 directory(options.slotDirectory)
69 writeExclusive(join(options.slotDirectory, 'image.bin'), image)
70 syncDirectory(options.slotDirectory)
71 if (!regularFile(join(options.slotDirectory, 'image.bin'), imageLimit).equals(built)) throw new Error('Host slot image readback failed')
72 await options.onReady()
73 if (!regularFile(path, imageLimit).equals(image)) throw new Error('Owned host executable changed before launch')
74 started = performance.now()
75 },
76 async onRequest(line) {
77 await options.onRequest(line)
78 if (line.startsWith('frame ')) {
79 const command = decodeRuntimeFrame(Uint8Array.fromHex(line.slice(6)))
80 if (command.kind === 'stop') stop = command
81 }
82 },
83 async onStatus(status) {
84 await options.onStatus(status)
85 if (last && status.sequence <= last.sequence) throw new Error('Host trial status sequence rejected')
86 last = status
87 },
88 })
89 if (!artifact || performance.now() - started > 30000 || !regularFile(artifact, imageLimit).equals(image)
90 || !regularFile(join(options.slotDirectory, 'image.bin'), imageLimit).equals(image)) throw new Error('Host trial image or duration rejected after execution')
91 if (!report.cleanProcessExit || !stop || !last || last.bootId !== stop.bootId || last.sessionId !== stop.sessionId || last.sessionId === 0n
92 || last.flags !== 67 || last.leaseUs !== 0 || last.deadlineMisses !== 0 || last.ticks !== 100 || last.sampleTimeUs !== 100000n
93 || last.stateBytes < 1 || last.stateBytes > 128 || last.maxExecutionUs > 500) throw new Error('Host trial stop response rejected')
94 return report
95}
resources/functions/drone/updates/store.tsmodified+248-7
Changes to resources/functions/drone/updates/store.ts
@@ -13,6 +13,9 @@ import type { RuntimeTrustSummary } from './enrollment'
1313import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files'
1414import { object, validateMetadata } from './metadata'
1515import { admitSignedRuntime, RuntimeManifestRejected } from './manifest'
16import { observeRuntimeHostImage, runtimeHostBuildProject } from './host-runtime'
17import { verifyRuntimeHostTranscript } from './host-evidence'
18import { encodeRuntimeFrame } from '../runtime/protocol'
1619
1720const applicationId = 0x44525453
1821const maximumGeneration = 10000
@@ -29,6 +32,12 @@ const candidateSchema = [
2932 'CREATE TABLE candidates (id TEXT PRIMARY KEY CHECK(length(id) = 32), attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN (\'reserved\', \'prepared\', \'retired\')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT',
3033 "CREATE UNIQUE INDEX candidate_active ON candidates ((1)) WHERE phase IN ('reserved', 'prepared')",
3134]
35const slotSchema = [
36 "CREATE TABLE host_slots (id TEXT PRIMARY KEY CHECK(length(id) = 32), candidate_id TEXT NOT NULL, attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN ('selected', 'running', 'confirmed', 'failed', 'abandoned')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), checked_ms INTEGER CHECK(checked_ms BETWEEN 1 AND 253402300799000), completed_ms INTEGER CHECK(completed_ms BETWEEN 1 AND 253402300799000), health BLOB CHECK(length(health) BETWEEN 1 AND 16384), FOREIGN KEY(candidate_id) REFERENCES candidates(id), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation), CHECK((phase = 'confirmed') = (completed_ms IS NOT NULL AND health IS NOT NULL)), CHECK(phase != 'confirmed' OR (checked_ms IS NOT NULL AND completed_ms = checked_ms))) STRICT",
37 "CREATE UNIQUE INDEX host_trial_active ON host_slots ((1)) WHERE phase IN ('selected', 'running')",
38 'CREATE TABLE host_roles (id INTEGER PRIMARY KEY CHECK(id = 1), installed_id TEXT, good_id TEXT, FOREIGN KEY(installed_id) REFERENCES host_slots(id), FOREIGN KEY(good_id) REFERENCES host_slots(id)) STRICT',
39 "CREATE TABLE host_inputs (slot_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 255), kind TEXT NOT NULL CHECK(kind IN ('tx', 'rx')), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND 160), PRIMARY KEY(slot_id, ordinal), FOREIGN KEY(slot_id) REFERENCES host_slots(id)) STRICT",
40]
3241
3342interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
3443interface StoreReference extends TrustReference { directory: string, storeSha256: string }
@@ -77,7 +86,8 @@ function openDatabase(path: string, expectedSchema: string[], allowCandidates =
7786 // Authenticate the protocol format before any configuration that could
7887 // rewrite a foreign database's journal/header format.
7988 const version = databaseVersion(db)
80 checkSchema(db, allowCandidates && version === 2 ? [...expectedSchema, ...candidateSchema] : expectedSchema, allowCandidates && version === 2 ? 2 : 1)
89 const additions = allowCandidates && version === 3 ? [...candidateSchema, ...slotSchema] : allowCandidates && version === 2 ? candidateSchema : []
90 checkSchema(db, [...expectedSchema, ...additions], allowCandidates && (version === 2 || version === 3) ? version : 1)
8191 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
8292 configure(db)
8393 return db
@@ -133,7 +143,8 @@ function summary(db: Database, binding: Binding, storeSha256: string): Readonly<
133143 for (const row of [anchor, ...metadata]) checkedMetadataBytes(row)
134144 const counts = db.query<{ attempts: number, metadata: number, inputs: number }, []>('SELECT (SELECT count(*) FROM attempts) AS attempts, (SELECT count(*) FROM metadata) AS metadata, (SELECT count(*) FROM inputs) AS inputs').get()!
135145 if (counts.attempts > 256 || counts.metadata !== current.generation + 1 || counts.metadata > maximumGeneration + 1 || counts.inputs > 16384) throw new Error('Update-store history budget exceeded or metadata history is incomplete')
136 const floors = db.query<{ clock: number, release: number, generation: number }, []>('SELECT coalesce((SELECT max(started_ms) FROM attempts), 0) AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation').get()!
146 const clockQuery = databaseVersion(db) === 3 ? 'max(coalesce((SELECT max(started_ms) FROM attempts), 0), coalesce((SELECT max(checked_ms) FROM host_slots), 0))' : 'coalesce((SELECT max(started_ms) FROM attempts), 0)'
147 const floors = db.query<{ clock: number, release: number, generation: number }, []>(`SELECT ${clockQuery} AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation`).get()!
137148 if (current.clock_floor_ms !== floors.clock || current.release_floor < floors.release || current.generation !== floors.generation || db.query<{ count: number }, []>("SELECT count(*) AS count FROM attempts WHERE phase IN ('pending', 'maintenance')").get()!.count > 1) throw new Error('Inconsistent update-store floors or attempts')
138149 const attempt = db.query<Attempt, []>('SELECT * FROM attempts ORDER BY rowid DESC LIMIT 1').get()
139150 return Object.freeze({ schema: 'drone-runtime-store-status-v1', storeId: binding.storeId, storeSha256, enrollmentSha256: binding.enrollmentSha256, generation: current.generation, epoch: metadata.find(row => row.role === 'root')!.epoch, releaseFloor: current.release_floor, clockFloorMs: current.clock_floor_ms, retainedMetadataRecords: counts.metadata, metadata: Object.freeze(metadata.map(row => Object.freeze({ role: row.role, version: row.version, sha256: row.sha256 }))), attempt: attempt ? Object.freeze({ id: attempt.id, phase: attempt.phase, clockSource: attempt.clock_source, startedMs: attempt.started_ms }) : null, physicalDeploymentAllowed: false })
@@ -153,7 +164,7 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b
153164 let writer: Database | undefined, db: Database | undefined
154165 try {
155166 for (const name of readdirSync(path)) {
156 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates'].includes(name)) throw new Error('Unexpected update-store entry')
167 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates', 'host-slots'].includes(name)) throw new Error('Unexpected update-store entry')
157168 if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true)
158169 }
159170 writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema])
@@ -162,13 +173,18 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b
162173 if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity')
163174 db = openDatabase(join(path, 'state.sqlite'), schema, true)
164175 if (readdirSync(path).includes('candidates')) {
165 if (databaseVersion(db) !== 2) throw new Error('Candidate files require an owned format2 store')
176 if (databaseVersion(db) < 2) throw new Error('Candidate files require an owned format2 or later store')
166177 protectedDirectory(join(path, 'candidates'))
167178 }
168179 const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
169180 if (initial?.sha256 !== trusted.policy.trustedRootSha256 || initial.version !== trusted.rootVersion || state(db, binding).release_floor < trusted.policy.minimumReleaseSequence) throw new Error('Update-store anchor or floor mismatch')
170181 summary(db, binding, options.storeSha256)
171182 for (const row of candidateRows(db)) ownedDescription(db, row, trusted)
183 if (readdirSync(path).includes('host-slots')) {
184 if (databaseVersion(db) !== 3) throw new Error('Host slot files require an owned format3 store')
185 protectedDirectory(join(path, 'host-slots'))
186 }
187 if (databaseVersion(db) === 3) slotsSummary(db, binding, trusted, path, options.storeSha256)
172188 return await operation(db, binding, trusted, path)
173189 }
174190 finally {
@@ -217,7 +233,7 @@ export interface RuntimeUpdateClock {
217233}
218234
219235export interface RuntimeUpdateProgress {
220 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired'
236 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired' | 'slot-selected' | 'slot-image-written' | 'runtime-started' | 'runtime-input-committed' | 'final-clock-committed' | 'runtime-confirmed'
221237 ordinal: number
222238}
223239type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
@@ -411,7 +427,7 @@ export interface RuntimeFirmwareCandidate {
411427}
412428export interface RuntimeFirmwareCandidates {
413429 schema: 'drone-runtime-candidates-v1'
414 stateFormat: 1 | 2
430 stateFormat: 1 | 2 | 3
415431 store: Readonly<RuntimeTrustStoreSummary>
416432 retainedCandidates: number
417433 /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */
@@ -495,7 +511,7 @@ function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<Run
495511 candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false })
496512 }
497513 }
498 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
514 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
499515}
500516
501517/** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */
@@ -548,6 +564,231 @@ export async function reserveRuntimeFirmwareCandidate(options: RefreshOptions):
548564 return Object.freeze({ ...result.value, store: result.store })
549565}
550566
567interface HostSlotHealth { bootId: string, sessionId: string, ticks: number, cleanProcessExit: true, transcriptItems: number, transcriptSha256: string }
568interface HostSlotRow { id: string, candidate_id: string, attempt_id: string, generation: number, phase: 'selected' | 'running' | 'confirmed' | 'failed' | 'abandoned', description: Uint8Array, sha256: string, checked_ms: number | null, completed_ms: number | null, health: Uint8Array | null }
569export interface RuntimeHostSlot {
570 id: string
571 candidateId: string
572 phase: HostSlotRow['phase']
573 clockCheckedMs: number | null
574 confirmedClockMs: number | null
575 description: Readonly<RuntimeFirmwareStage>
576 health: Readonly<HostSlotHealth> | null
577 integrity: 'recorded-bytes-match' | 'mismatch' | 'unavailable'
578 currentTrustValidated: false
579}
580export interface RuntimeFirmwareSlots {
581 schema: 'drone-runtime-host-slots-v1'
582 stateFormat: 1 | 2 | 3
583 store: Readonly<RuntimeTrustStoreSummary>
584 installed: Readonly<RuntimeHostSlot> | null
585 knownGood: Readonly<RuntimeHostSlot> | null
586 trial: Readonly<RuntimeHostSlot> | null
587 retainedSlots: number
588 physicalDeploymentAllowed: false
589}
590function hostRows(db: Database) {
591 if (databaseVersion(db) < 3) return []
592 const rows = db.query<HostSlotRow, []>('SELECT * FROM host_slots ORDER BY rowid LIMIT 129').all()
593 if (rows.length > 128) throw new Error('Host slot history budget exceeded')
594 return rows
595}
596function hostTranscript(db: Database, id: string) {
597 const inputs = db.query<{ ordinal: number, kind: 'tx' | 'rx', bytes: Uint8Array }, [string]>('SELECT ordinal, kind, bytes FROM host_inputs WHERE slot_id = ? ORDER BY ordinal LIMIT 257').all(id)
598 if (inputs.length > 256 || inputs.some((item, index) => item.ordinal !== index || item.kind !== (index % 2 ? 'rx' : 'tx') || (item.kind === 'rx' && item.bytes.length !== 64))) throw new Error('Invalid host health input history')
599 return { inputs, digest: sha256(Buffer.from(JSON.stringify(inputs.map(item => ({ kind: item.kind, hex: Buffer.from(item.bytes).toString('hex') }))))) }
600}
601function slotHealth(db: Database, row: HostSlotRow): Readonly<HostSlotHealth> | null {
602 const attempt = db.query<Attempt, [string]>('SELECT * FROM attempts WHERE id = ?').get(row.attempt_id)
603 if (!attempt || (row.checked_ms !== null && row.checked_ms < attempt.started_ms)) throw new Error('Invalid retained host clock observation')
604 if (row.phase !== 'confirmed') {
605 if (row.health !== null || row.completed_ms !== null) throw new Error('Incomplete host slot has a health confirmation')
606 hostTranscript(db, row.id)
607 return null
608 }
609 if (!row.health || row.completed_ms === null || row.checked_ms !== row.completed_ms) throw new Error('Confirmed host slot lacks health or clock evidence')
610 const value = object(JSON.parse(Buffer.from(row.health).toString('utf8')))
611 const keys = ['bootId', 'sessionId', 'ticks', 'cleanProcessExit', 'transcriptItems', 'transcriptSha256']
612 const transcript = hostTranscript(db, row.id)
613 if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key))
614 || ![value.bootId, value.sessionId].every(id => typeof id === 'string' && /^[1-9][0-9]{0,19}$/.test(id) && BigInt(id) <= 0xFFFFFFFFFFFFFFFFn)
615 || value.ticks !== 100 || value.cleanProcessExit !== true || value.transcriptItems !== 214 || transcript.inputs.length !== value.transcriptItems || value.transcriptSha256 !== transcript.digest) throw new Error('Invalid retained host health confirmation')
616 verifyRuntimeHostTranscript(transcript.inputs, { bootId: value.bootId as string, sessionId: value.sessionId as string })
617 return Object.freeze(value as unknown as HostSlotHealth)
618}
619function slotsSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string, storeSha256: string): Readonly<RuntimeFirmwareSlots> {
620 const rows = hostRows(db), slots = new Map<string, Readonly<RuntimeHostSlot>>()
621 for (const row of rows) {
622 const description = ownedDescription(db, { ...row, phase: 'prepared' }, trusted)
623 const candidate = candidateRows(db).find(item => item.id === row.candidate_id)
624 if (!candidate) throw new Error('Host slot candidate is missing')
625 const original = ownedDescription(db, candidate, trusted)
626 if (description.platform !== 'host-neutral-bench-v1' || original.sha256 !== description.sha256 || original.length !== description.length || original.sourceRevision !== description.sourceRevision || original.releaseSequence !== description.releaseSequence) throw new Error('Host slot does not match its owned candidate')
627 let integrity: RuntimeHostSlot['integrity'] = 'unavailable'
628 try {
629 const directory = join(path, 'host-slots', row.id), file = join(directory, 'image.bin')
630 protectedDirectory(directory)
631 if (readdirSync(directory).length !== 1) integrity = 'mismatch'
632 else {
633 protectedFile(file, imageLimit)
634 const image = regularFile(file, imageLimit)
635 integrity = image.length === description.length && sha256(image) === description.sha256 ? 'recorded-bytes-match' : 'mismatch'
636 }
637 }
638 catch { integrity = 'unavailable' }
639 slots.set(row.id, Object.freeze({ id: row.id, candidateId: row.candidate_id, phase: row.phase, clockCheckedMs: row.checked_ms, confirmedClockMs: row.completed_ms, description, health: slotHealth(db, row), integrity, currentTrustValidated: false }))
640 }
641 let installed: Readonly<RuntimeHostSlot> | null = null, knownGood: Readonly<RuntimeHostSlot> | null = null
642 if (databaseVersion(db) === 3) {
643 const roles = db.query<{ id: number, installed_id: string | null, good_id: string | null }, []>('SELECT * FROM host_roles').all()
644 if (roles.length !== 1 || roles[0]!.id !== 1) throw new Error('Invalid host slot roles')
645 const role = roles[0]!
646 installed = role.installed_id ? slots.get(role.installed_id) ?? null : null
647 knownGood = role.good_id ? slots.get(role.good_id) ?? null : null
648 if ((role.installed_id && !installed) || (role.good_id && (!knownGood || knownGood.phase !== 'confirmed')) || installed?.phase === 'selected') throw new Error('Invalid installed or known-good host role')
649 }
650 return Object.freeze({ schema: 'drone-runtime-host-slots-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3, store: summary(db, binding, storeSha256), installed, knownGood, trial: rows.length ? slots.get(rows.at(-1)!.id)! : null, retainedSlots: rows.length, physicalDeploymentAllowed: false })
651}
652
653/** Historical host role/evidence inspection. This never authorizes use under current trust. */
654export async function inspectRuntimeFirmwareSlots(options: StoreReference): Promise<Readonly<RuntimeFirmwareSlots>> {
655 return withStore(options, (db, binding, trusted, path) => slotsSummary(db, binding, trusted, path, options.storeSha256))
656}
657
658/** Abandon only the exact interrupted host trial after metadata recovery. Retain every artifact and known-good role. */
659export async function abandonRuntimeFirmwareTrial(options: StoreReference & { trialId: string }): Promise<Readonly<RuntimeFirmwareSlots>> {
660 options = { ...options }
661 if (typeof options.trialId !== 'string' || !/^[a-f0-9]{32}$/.test(options.trialId)) throw new Error('Exact interrupted host trial identity required')
662 return withStore(options, (db, binding, trusted, path) => {
663 const current = summary(db, binding, options.storeSha256)
664 if (current.attempt?.phase === 'pending' || current.attempt?.phase === 'maintenance') throw new Error('Resolve metadata before abandoning a host trial')
665 const row = hostRows(db).find(item => item.id === options.trialId && (item.phase === 'selected' || item.phase === 'running'))
666 if (!row) throw new Error('Exact interrupted host trial identity required')
667 const changed = db.query("UPDATE host_slots SET phase = 'abandoned' WHERE id = ? AND phase IN ('selected', 'running')").run(row.id)
668 if (changed.changes !== 1) throw new Error('Host trial abandonment did not commit')
669 return slotsSummary(db, binding, trusted, path, options.storeSha256)
670 })
671}
672
673/** Fresh signed selection and actual owned host health. No caller health receipt is accepted. */
674export async function trialRuntimeFirmwareCandidate(options: RefreshOptions & { candidateId: string, project: string }): Promise<Readonly<RuntimeFirmwareSlots>> {
675 options = { ...options }
676 if (typeof options.candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(options.candidateId)) throw new Error('Exact owned candidate identity required')
677 return runHostTrial(options, options.candidateId)
678}
679
680/** Rollback re-verifies current policy and observes a new actual health session for the retained good image. */
681export async function rollbackRuntimeFirmwareSlot(options: RefreshOptions & { project: string }): Promise<Readonly<RuntimeFirmwareSlots>> {
682 return runHostTrial(options)
683}
684
685async function runHostTrial(options: RefreshOptions & { project: string }, candidateId?: string): Promise<Readonly<RuntimeFirmwareSlots>> {
686 options = { ...options }
687 let rollbackSlotId: string | undefined
688 if (typeof options.project !== 'string' || !options.project) throw new Error('Trusted local source project required')
689 options = { ...options, project: runtimeHostBuildProject(options.project, [options.directory, options.enrollmentDirectory, options.repository]) }
690 if (!options.clock || typeof options.clock.read !== 'function') throw new Error('An independently trusted update clock is required')
691 const declaredClock = options.clock, readClock = declaredClock.read
692 options = { ...options, clock: { source: declaredClock.source, read: () => readClock.call(declaredClock) } }
693 const result = await refreshAndComplete(options, async selection => {
694 const { db, binding, trusted, path, attempt } = selection
695 if (rollbackSlotId) {
696 const retained = slotsSummary(db, binding, trusted, path, options.storeSha256).knownGood
697 if (!retained || retained.id !== rollbackSlotId || retained.integrity !== 'recorded-bytes-match') throw new Error('Retained known-good integrity failed before rollback')
698 }
699 const row = candidateRows(db).find(item => item.id === candidateId)
700 if (!row || row.phase === 'reserved') throw new Error('A complete owned candidate is required for host trial')
701 const description = candidateDescription(selection), original = ownedDescription(db, row, trusted)
702 if (description.platform !== 'host-neutral-bench-v1' || original.sha256 !== description.sha256 || original.length !== description.length || original.sourceRevision !== description.sourceRevision || original.releaseSequence !== description.releaseSequence) throw new Error('Owned candidate is not the currently authorized host image')
703 const candidatePath = join(path, 'candidates', row.id)
704 if (candidateIntegrity(db, row.generation, candidatePath, original) !== 'recorded-bytes-match') throw new Error('Host trial candidate integrity failed')
705 const image = regularFile(join(candidatePath, 'image.bin'), imageLimit)
706 await selection.info.verify(Readable.from(image))
707 if (databaseVersion(db) === 2) {
708 db.transaction(() => {
709 for (const sql of slotSchema) db.exec(sql)
710 db.exec('INSERT INTO host_roles VALUES(1, NULL, NULL); PRAGMA user_version=3')
711 })()
712 checkSchema(db, [...schema, ...candidateSchema, ...slotSchema], 3)
713 }
714 const id = randomBytes(16).toString('hex'), bytes = Buffer.from(`${JSON.stringify(description)}\n`)
715 db.query("INSERT INTO host_slots VALUES(?, ?, ?, ?, 'selected', ?, ?, NULL, NULL, NULL)").run(id, row.id, attempt, state(db, binding).generation, bytes, sha256(bytes))
716 try {
717 await options.onProgress?.(Object.freeze({ phase: 'slot-selected', ordinal: 0 }))
718 const parent = join(path, 'host-slots')
719 if (!readdirSync(path).includes('host-slots')) { mkdirSync(parent, { mode: 0o700 }); syncDirectory(path) }
720 protectedDirectory(parent)
721 const slotDirectory = join(parent, id)
722 mkdirSync(slotDirectory, { mode: 0o700 }); syncDirectory(parent)
723 let ordinal = 0
724 const append = (kind: 'tx' | 'rx', bytes: Buffer) => {
725 if (ordinal >= 256 || bytes.length < 1 || bytes.length > 160) throw new Error('Host trial input budget exceeded')
726 const committed = ordinal
727 db.query('INSERT INTO host_inputs VALUES(?, ?, ?, ?)').run(id, ordinal, kind, bytes)
728 ordinal++
729 return committed
730 }
731 const report = await observeRuntimeHostImage({ project: options.project, description, candidateImage: join(candidatePath, 'image.bin'), slotDirectory, protectedDirectories: [path, options.enrollmentDirectory, options.repository],
732 async onReady() {
733 await options.onProgress?.(Object.freeze({ phase: 'slot-image-written', ordinal: 0 }))
734 db.transaction(() => {
735 const changed = db.query("UPDATE host_slots SET phase = 'running' WHERE id = ? AND phase = 'selected'").run(id)
736 if (changed.changes !== 1) throw new Error('Selected host slot changed before runtime launch')
737 db.query('UPDATE host_roles SET installed_id = ? WHERE id = 1').run(id)
738 })()
739 await options.onProgress?.(Object.freeze({ phase: 'runtime-started', ordinal: 0 }))
740 },
741 async onRequest(line) { const ordinal = append('tx', Buffer.from(line)); await options.onProgress?.(Object.freeze({ phase: 'runtime-input-committed', ordinal })) },
742 async onStatus(status) { const ordinal = append('rx', Buffer.from(encodeRuntimeFrame(status))); await options.onProgress?.(Object.freeze({ phase: 'runtime-input-committed', ordinal })) },
743 })
744 const clockStarted = performance.now(), completed = clockReading(options.clock, state(db, binding).clock_floor_ms)
745 db.transaction(() => {
746 const changed = db.query('UPDATE host_slots SET checked_ms = ? WHERE id = ?').run(completed.time.getTime(), id)
747 if (changed.changes !== 1) throw new Error('Host clock observation lost its owned slot')
748 db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(completed.time.getTime())
749 })()
750 await options.onProgress?.(Object.freeze({ phase: 'final-clock-committed', ordinal: 0 }))
751 const elapsed = performance.now() - clockStarted
752 if (elapsed > 1000) throw new Error('Host confirmation clock sample became stale')
753 for (const metadata of activeMetadata(db, state(db, binding).generation)) {
754 const expires = object(object(JSON.parse(checkedMetadataBytes(metadata).toString('utf8'))).signed).expires
755 if (typeof expires !== 'string' || !Number.isFinite(Date.parse(expires)) || completed.time.getTime() + Math.ceil(elapsed) >= Date.parse(expires)) throw new Error('Host trial metadata expired before confirmation')
756 }
757 const transcript = hostTranscript(db, id)
758 const health: HostSlotHealth = { bootId: report.bootId, sessionId: report.sessionId, ticks: report.ticks, cleanProcessExit: true, transcriptItems: transcript.inputs.length, transcriptSha256: transcript.digest }
759 if (health.transcriptItems !== 214) throw new Error('Host trial transcript is incomplete')
760 verifyRuntimeHostTranscript(transcript.inputs, health)
761 db.transaction(() => {
762 const confirmed = db.query("UPDATE host_slots SET phase = 'confirmed', completed_ms = ?, health = ? WHERE id = ? AND phase = 'running'").run(completed.time.getTime(), Buffer.from(JSON.stringify(health)), id)
763 if (confirmed.changes !== 1) throw new Error('Running host slot changed before confirmation')
764 db.query('UPDATE host_roles SET good_id = ? WHERE id = 1').run(id)
765 db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(completed.time.getTime())
766 const retired = db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase = 'prepared'").run(row.id)
767 if (retired.changes !== (row.phase === 'prepared' ? 1 : 0)) throw new Error('Owned candidate reservation changed before confirmation')
768 })()
769 await options.onProgress?.(Object.freeze({ phase: 'runtime-confirmed', ordinal: 0 }))
770 return slotsSummary(db, binding, trusted, path, options.storeSha256)
771 }
772 catch (error) {
773 try { db.query("UPDATE host_slots SET phase = 'failed' WHERE id = ? AND phase IN ('selected', 'running')").run(id) }
774 catch (persistence) { throw new AggregateError([error, persistence], 'Host trial failed and its failure state could not be recorded') }
775 throw error
776 }
777 }, db => {
778 if (hostRows(db).some(row => row.phase === 'selected' || row.phase === 'running')) throw new Error('Interrupted host trial requires explicit recovery')
779 if (hostRows(db).length >= 128) throw new Error('Host slot history budget reached')
780 if (!candidateId) {
781 if (databaseVersion(db) !== 3) throw new Error('No retained known-good host image for rollback')
782 const role = db.query<{ good_id: string | null }, []>('SELECT good_id FROM host_roles WHERE id = 1').get()
783 const previous = hostRows(db).find(row => row.id === role?.good_id && row.phase === 'confirmed')
784 if (!previous) throw new Error('No retained known-good host image for rollback')
785 candidateId = previous.candidate_id
786 rollbackSlotId = previous.id
787 }
788 })
789 return Object.freeze({ ...result.value, store: result.store })
790}
791
551792function causedBy(error: unknown, marker: Error) {
552793 const seen = new Set<unknown>()
553794 for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) {
resources/functions/host/process.tsadded+2-0
Changes to resources/functions/host/process.ts
@@ -0,0 +1,2 @@
1export { runBoundedProcess as runBoundedHostProcess } from '../artifacts/native-cli/process'
2export type { BoundedProcessOptions as BoundedHostProcessOptions } from '../artifacts/native-cli/process'
scripts/parity-process.tsmodified+2-60
Changes to scripts/parity-process.ts
@@ -1,60 +1,2 @@
1export interface ParityProcessOptions {
2 cwd?: string
3 env?: Record<string, string | undefined>
4 timeoutMs: number
5 stdoutBytes: number
6 stderrBytes: number
7 label: string
8}
9
10/** Owns a finite command, its output readers and, on POSIX, its process group. */
11export async function runParityProcess(command: string[], options: ParityProcessOptions) {
12 if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 60000) throw new Error('Invalid parity process timeout')
13 for (const limit of [options.stdoutBytes, options.stderrBytes]) {
14 if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid parity process output limit')
15 }
16 const ownsGroup = process.platform !== 'win32'
17 const child = Bun.spawn(command, { cwd: options.cwd, env: options.env ?? process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: ownsGroup })
18 const readers = [child.stdout.getReader(), child.stderr.getReader()]
19 const cancellations: Promise<unknown>[] = []
20 let failure: Error | undefined, stopped = false
21 function terminate() {
22 if (ownsGroup) { try { process.kill(-child.pid, 'SIGKILL') } catch { /* The group may already be gone. */ } }
23 try { child.kill('SIGKILL') } catch { /* The direct child may already have exited. */ }
24 }
25 function stop(error: Error) {
26 failure ??= error
27 if (stopped) return
28 stopped = true
29 terminate()
30 for (const reader of readers) cancellations.push(reader.cancel().catch(() => {}))
31 }
32 async function consume(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number, label: string) {
33 const chunks: Uint8Array[] = []
34 let bytes = 0
35 try {
36 while (true) {
37 const { done, value } = await reader.read()
38 if (done) return Buffer.concat(chunks, bytes).toString('utf8')
39 if (value.byteLength > limit - bytes) throw new Error(`${options.label} ${label} exceeds byte limit`)
40 bytes += value.byteLength
41 chunks.push(value)
42 }
43 }
44 catch (error) { stop(error instanceof Error ? error : new Error(String(error))); throw error }
45 }
46 const timer = setTimeout(() => stop(new Error(`${options.label} timed out`)), options.timeoutMs)
47 const stdout = consume(readers[0]!, options.stdoutBytes, 'stdout'), stderr = consume(readers[1]!, options.stderrBytes, 'stderr')
48 try {
49 const [exitCode, output, diagnostic] = await Promise.all([child.exited, stdout, stderr])
50 if (failure) throw failure
51 return { exitCode, stdout: output, stderr: diagnostic }
52 }
53 finally {
54 clearTimeout(timer)
55 terminate()
56 if (child.exitCode === null) stop(new Error(`${options.label} interrupted`))
57 await Promise.allSettled([child.exited, stdout, stderr, ...cancellations])
58 for (const reader of readers) reader.releaseLock()
59 }
60}
1export { runBoundedHostProcess as runParityProcess } from '../resources/functions/host/process'
2export type { BoundedHostProcessOptions as ParityProcessOptions } from '../resources/functions/host/process'
tests/fixtures/public-safety-access/purge-history.tsmodified+46-10
Changes to tests/fixtures/public-safety-access/purge-history.ts
@@ -1,6 +1,7 @@
11import assert from 'node:assert/strict'
2import { setSystemTime } from 'bun:test'
23import { randomUUID } from 'node:crypto'
3import { actor, as, seed, database, records, managed } from './managed-context'
4import { actor, as, seed, database, records, managed, status } from './managed-context'
45const purge = await import('../../../resources/functions/drone/public-safety/purge/service')
56const { purgeAbilities, purgePolicy, purgeCoverage, purgeExclusions } = await import('../../../resources/functions/drone/public-safety/purge/types')
67const { publicSafetyPurgeGate } = await import('../../../resources/functions/drone/public-safety/purge/policy')
@@ -11,17 +12,52 @@ const coordinator = await actor('purge-history-coordinator')
1112try {
1213 process.env.PUBLIC_SAFETY_PURGE_ENABLED = 'true'
1314 await as(coordinator.token.plainTextToken, async () => {
14 const key = 'purge-history', fixture = await seed(key, 60000)
15 const capture = fixture.run.survey.captures[0]!, support = fixture.run.captures[0]!.support.findIndex(value => value === 1)
16 const observation = await records.appendPublicSafetyObservation(key, { expectedVersion: 3, sourceDigest: fixture.source.digest, captureId: capture.id, modality: 'visible', pixel: { x: support % capture.width, y: Math.floor(support / capture.width) }, note: 'private-history-observation-fragment', uncertaintyNote: 'private-history-uncertainty-fragment' })
17 let version = 4, previousReviewId: number | null = null
18 for (let count = 0; count < 137; count++) {
19 const appended = await records.appendPublicSafetyReview(key, observation.observation.id, { expectedVersion: version, sourceDigest: fixture.source.digest, previousReviewId, disposition: 'unresolved', note: `private-history-review-fragment-${count}` })
20 version = appended.incident.version; previousReviewId = appended.review.id
21 }
22 await managed.createPublicSafetyManagedDataset(key, { ...fixture.body, expectedVersion: version })
15 const key = 'purge-history'
16 // Controlled preparation removes an unrelated host-throughput precondition.
17 // The installed Stacks testing root registers afterEach, so this standalone
18 // child uses its underlying Bun clock API. Real timers remain unchanged.
19 const fixture = await (async () => {
20 const preparationAt = Date.now()
21 const realNow = () => {
22 setSystemTime()
23 try { return Date.now() }
24 finally { setSystemTime(preparationAt) }
25 }
26 setSystemTime(preparationAt)
27 try {
28 const prepared = await seed(key, 60000)
29 const capture = prepared.run.survey.captures[0]!, support = prepared.run.captures[0]!.support.findIndex(value => value === 1)
30 const observation = await records.appendPublicSafetyObservation(key, { expectedVersion: 3, sourceDigest: prepared.source.digest, captureId: capture.id, modality: 'visible', pixel: { x: support % capture.width, y: Math.floor(support / capture.width) }, note: 'private-history-observation-fragment', uncertaintyNote: 'private-history-uncertainty-fragment' })
31 let version = 4, previousReviewId: number | null = null
32 for (let count = 0; count < 137; count++) {
33 if (count === 136) {
34 // Exercise the reproduced precondition even on a fast host. This
35 // spends the existing expiry wait before the last setup write.
36 const expires = Date.parse(prepared.config.retention.expiresAt)
37 const remaining = expires - realNow() + 20
38 if (remaining > 0) await Bun.sleep(remaining)
39 assert(realNow() >= expires, 'The final controlled setup review must cross real retention expiry')
40 }
41 const appended = await records.appendPublicSafetyReview(key, observation.observation.id, { expectedVersion: version, sourceDigest: prepared.source.digest, previousReviewId, disposition: 'unresolved', note: `private-history-review-fragment-${count}` })
42 version = appended.incident.version; previousReviewId = appended.review.id
43 }
44 await managed.createPublicSafetyManagedDataset(key, { ...prepared.body, expectedVersion: version })
45 const beforeReviews = await database.db.primary.selectFrom('public_safety_reviews').select('id').execute()
46 const beforeAudits = await database.db.primary.selectFrom('public_safety_audits').select('id').execute()
47 const incident = (await database.db.primary.selectFrom('public_safety_incidents').select('version').where('incident_key', '=', key).executeTakeFirst())!
48 assert.equal(beforeReviews.length, 137)
49 setSystemTime(Date.parse(prepared.config.retention.expiresAt))
50 await assert.rejects(() => records.appendPublicSafetyReview(key, observation.observation.id, { expectedVersion: incident.version, sourceDigest: prepared.source.digest, previousReviewId, disposition: 'unresolved', note: 'refused-at-exact-retention-boundary' }), status(404))
51 assert.deepEqual(await database.db.primary.selectFrom('public_safety_reviews').select('id').execute(), beforeReviews)
52 assert.deepEqual(await database.db.primary.selectFrom('public_safety_audits').select('id').execute(), beforeAudits)
53 return prepared
54 }
55 finally { setSystemTime() }
56 })()
2357 const wait = Date.parse(fixture.config.retention.expiresAt) - Date.now() + 20
2458 if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait))
59 assert(Date.now() > Date.parse(fixture.config.retention.expiresAt), 'Purge acceptance must use genuinely elapsed wall time after restoring the clock')
60 console.log('PASS controlled setup crosses real60000ms expiry before its last review and refuses exact-expiry review before genuinely due purge')
2561 const preview = await purge.previewPublicSafetyPurge(key)
2662 assert.equal(preview.counts.reviews, 137); assert(preview.counts.audits > 100)
2763 const accepted = await purge.acceptPublicSafetyPurge(key, { expectedVersion: preview.incidentVersion, expectedRetentionVersion: preview.retentionVersion, policyVersion: purgePolicy, coverage: purgeCoverage, inventoryDigest: preview.inventoryDigest, exclusions: purgeExclusions, receiptRetentionDays: 2, requestId: randomUUID() })
tests/fixtures/runtime/full-input.cppadded+37-0
Changes to tests/fixtures/runtime/full-input.cpp
@@ -0,0 +1,37 @@
1// Linux-only host transport fixture. Observe a full FIFO without consuming input.
2#ifndef _GNU_SOURCE
3#define _GNU_SOURCE
4#endif
5#include "protocol.hpp"
6#include <cstdio>
7#include <cstdlib>
8#include <fcntl.h>
9#include <sys/ioctl.h>
10#include <unistd.h>
11
12int main(int argc, char** argv) {
13 const auto page = sysconf(_SC_PAGESIZE);
14 if (argc != 4 || page < 4096 || page > 65536) return 2;
15 const int capacity = fcntl(STDIN_FILENO, F_SETPIPE_SZ, int(page));
16 if (capacity != page) return 2;
17 drone::Frame status;
18 status.kind = drone::Kind::status;
19 status.boot = std::strtoull(argv[3], nullptr, 16);
20 status.sequence = uint32_t(getpid());
21 status.state_bytes = uint32_t(capacity);
22 int previous = 0;
23 for (;;) {
24 int queued = 0;
25 if (ioctl(STDIN_FILENO, FIONREAD, &queued) != 0) return 3;
26 if (queued > 0 && queued != previous) {
27 previous = queued;
28 status.sample = queued;
29 uint8_t bytes[drone::frame_size];
30 drone::encode(status, bytes);
31 for (auto byte : bytes) std::printf("%02x", byte);
32 std::puts("");
33 std::fflush(stdout);
34 }
35 usleep(1000);
36 }
37}
tests/fixtures/updates/host-project.tsadded+34-0
Changes to tests/fixtures/updates/host-project.ts
@@ -0,0 +1,34 @@
1import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
2import { join } from 'node:path'
3
4/** Actual disposable source repo with explicit stop-reply or configure-output faults. */
5export async function createHostSourceProject(root: string, options: { fault?: 'stop-session' | 'configure-output' } = {}) {
6 const project = join(root, 'project')
7 await mkdir(project, { mode: 0o700 })
8 await mkdir(join(project, 'firmware'), { mode: 0o700 })
9 await cp(join(import.meta.dir, '../../../firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true })
10 if (options.fault === 'stop-session') {
11 const path = join(project, 'firmware/runtime/src/host.cpp'), source = await readFile(path, 'utf8')
12 const original = 'drone::encode(runtime.status(now), bytes);'
13 if (!source.includes(original)) throw new Error('Owned host source no longer matches the deliberate stop-session fixture')
14 await writeFile(path, source.replace(original, 'auto status = runtime.status(now); if (std::strncmp(line, "frame 44520103", 14) == 0) ++status.session; drone::encode(status, bytes);'))
15 }
16 if (options.fault === 'configure-output') {
17 const path = join(project, 'firmware/runtime/CMakeLists.txt')
18 await writeFile(path, `${await readFile(path, 'utf8')}\nif(EXISTS "\${CMAKE_BINARY_DIR}/drone-output-overflow-fixture")\n string(REPEAT "fixture-only-output " 180000 DRONE_OVERFLOW_FIXTURE)\n message(STATUS "\${DRONE_OVERFLOW_FIXTURE}")\nendif()\n`)
19 }
20 async function git(args: string[]) {
21 const child = Bun.spawn(['git', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'commit.gpgsign=false', '-c', 'user.name=Chris Breuer', '-c', 'user.email=chris@stacksjs.com', '-C', project, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
22 const timer = setTimeout(() => child.kill('SIGKILL'), 10000)
23 try {
24 const [code, output, errors] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()])
25 if (code) throw new Error('Isolated host fixture Git command failed', { cause: { code, errors } })
26 return output.trim()
27 }
28 finally { clearTimeout(timer) }
29 }
30 await git(['init', '-b', 'codex/host-health-fixture'])
31 await git(['add', 'firmware'])
32 await git(['commit', '-m', 'test: prepare host health fixture'])
33 return { project, sourceRevision: await git(['rev-parse', 'HEAD']) }
34}
tests/fixtures/updates/host-slot-interruptions.tsadded+101-0
Changes to tests/fixtures/updates/host-slot-interruptions.ts
@@ -0,0 +1,101 @@
1import assert from 'node:assert/strict'
2import { spawnSync } from 'node:child_process'
3import { mkdir, readFile, writeFile } from 'node:fs/promises'
4import { join } from 'node:path'
5import { buildRuntime } from '../../../resources/functions/drone/runtime/build'
6import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
7import { abandonRuntimeFirmwareTrial, createRuntimeTrustStore, inspectRuntimeFirmwareSlots, inspectRuntimeTrustStore, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, rollbackRuntimeFirmwareSlot, trialRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
8import { repository, target } from './repository'
9
10type Reference = Parameters<typeof inspectRuntimeTrustStore>[0]
11const clock = { source: 'independent-fixture-clock', read: () => new Date('2030-01-01T00:00:00Z') }
12interface Input { reference: Reference, source: string, project: string, candidateId: string, cut: string, systemClock?: boolean }
13async function worker(mode: string, file: string) {
14 const input = JSON.parse(await readFile(file, 'utf8')) as Input
15 if (mode === 'contend') {
16 try { await inspectRuntimeTrustStore(input.reference); process.exitCode = 18 }
17 catch (error) { process.exitCode = (error as NodeJS.ErrnoException).code === 'SQLITE_BUSY' ? 17 : 19 }
18 return
19 }
20 if (mode !== 'hold') throw new Error('Unknown host trial worker mode')
21 await trialRuntimeFirmwareCandidate({ ...input.reference, repository: input.source, project: input.project, candidateId: input.candidateId, clock: input.systemClock ? { source: 'fixture-system-clock', read: () => new Date() } : clock, async onProgress(event) {
22 if (event.phase !== input.cut || event.ordinal !== (input.cut === 'runtime-input-committed' ? 51 : 0)) return
23 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
24 await Bun.stdin.text()
25 } })
26 throw new Error('Host trial interruption worker unexpectedly completed')
27}
28
29/** Actual process cuts and exclusive writers. Retains generated repositories/state when the caller retains directory. */
30export async function qualifyHostSlotInterruptions(directory: string, project: string) {
31 await mkdir(directory, { mode: 0o700 })
32 const build = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer())
33 const git = spawnSync('git', ['-C', project, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10000 })
34 assert.equal(git.status, 0)
35 const sourceRevision = git.stdout.trim(), results = []
36 for (const cut of ['slot-selected', 'slot-image-written', 'runtime-started', 'runtime-input-committed', 'final-clock-committed', 'runtime-confirmed']) {
37 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
38 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
39 const enrollmentDirectory = join(root, 'enrollment')
40 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
41 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
42 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
43 const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
44 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id })
45 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }), file = join(root, 'worker.json')
46 await writeFile(file, JSON.stringify({ reference, source, project, cut, candidateId: next.candidate!.id } satisfies Input), { mode: 0o600 })
47 // These are library-process workers, not Buddy boot tests. Keep application preloads out.
48 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { cwd: root, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
49 const timer = setTimeout(() => child.kill('SIGKILL'), 15000), reader = child.stdout.getReader()
50 let stdout = ''
51 try {
52 while (!stdout.includes('\n')) {
53 const chunk = await reader.read()
54 if (chunk.done) throw new Error('Host trial worker exited before readiness')
55 stdout += new TextDecoder().decode(chunk.value)
56 if (stdout.length > 4096) throw new Error('Host trial worker output exceeded bounds')
57 }
58 const ready = JSON.parse(stdout.trim()); assert.equal(ready.ready, true); assert.equal(ready.phase, cut)
59 const contender = spawnSync(process.execPath, [import.meta.path, 'contend', file], { cwd: root, encoding: 'utf8', timeout: 10000 })
60 await writeFile(join(root, 'contender.stdout'), contender.stdout ?? '')
61 await writeFile(join(root, 'contender.stderr'), contender.stderr ?? '')
62 await writeFile(join(root, 'contender.json'), JSON.stringify({ status: contender.status, signal: contender.signal, error: contender.error?.message }))
63 assert.equal(contender.status, 17)
64 child.kill('SIGKILL'); const exit = await child.exited
65 assert.equal(child.signalCode, 'SIGKILL'); assert.notEqual(exit, 0)
66 const stopped = await inspectRuntimeFirmwareSlots(reference), confirmed = cut === 'runtime-confirmed'
67 assert.equal(stopped.store.attempt!.phase, 'pending')
68 assert.equal(stopped.knownGood!.id, confirmed ? stopped.trial!.id : good.knownGood!.id)
69 assert.equal(stopped.store.releaseFloor, 7)
70 assert.deepEqual(await readFile(join(reference.directory, 'host-slots', good.knownGood!.id, 'image.bin')), Buffer.from(image))
71 await recoverRuntimeTrustStore(reference)
72 const recovered = await inspectRuntimeFirmwareSlots(reference)
73 assert.deepEqual(recovered.knownGood, stopped.knownGood)
74 assert.equal(recovered.trial!.phase, stopped.trial!.phase)
75 if (!confirmed) {
76 await assert.rejects(rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project }), /explicit recovery/)
77 await assert.rejects(abandonRuntimeFirmwareTrial({ ...reference, trialId: '0'.repeat(32) }), /Exact interrupted/)
78 const abandoned = await abandonRuntimeFirmwareTrial({ ...reference, trialId: stopped.trial!.id })
79 assert.equal(abandoned.trial!.phase, 'abandoned')
80 assert.deepEqual(abandoned.knownGood, good.knownGood)
81 }
82 const rollback = await rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project })
83 assert.equal(rollback.knownGood!.candidateId, stopped.knownGood!.candidateId)
84 assert.notEqual(rollback.knownGood!.health!.bootId, stopped.knownGood!.health!.bootId)
85 assert.notEqual(rollback.knownGood!.health!.sessionId, stopped.knownGood!.health!.sessionId)
86 assert.equal(rollback.installed!.id, rollback.knownGood!.id)
87 assert.equal(rollback.physicalDeploymentAllowed, false)
88 results.push({ cut, exit, signal: child.signalCode, contender: contender.status, stoppedPhase: stopped.trial!.phase, confirmedBeforeCut: confirmed, recoveredWithoutPromotion: true, freshRollback: true })
89 }
90 finally {
91 clearTimeout(timer); child.kill('SIGKILL'); await child.exited
92 await writeFile(join(root, 'worker.stdout'), stdout)
93 await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text())
94 await reader.cancel(); reader.releaseLock()
95 }
96 }
97 await writeFile(join(directory, 'results.json'), JSON.stringify(results, null, 2))
98 return results
99}
100
101if (import.meta.main) await worker(Bun.argv[2]!, Bun.argv[3]!)
tests/fixtures/updates/qualify-host-slots.tsadded+114-0
Changes to tests/fixtures/updates/qualify-host-slots.ts
@@ -0,0 +1,114 @@
1import assert from 'node:assert/strict'
2import { constants } from 'node:fs'
3import { createHash } from 'node:crypto'
4import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
5import { join, resolve } from 'node:path'
6import { buildRuntime } from '../../../resources/functions/drone/runtime/build'
7import { runBoundedHostProcess } from '../../../resources/functions/host/process'
8import { qualifyHostSlotInterruptions } from './host-slot-interruptions'
9import { repository, target } from './repository'
10
11async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
12
13/** Copied CLI, actual signed host bytes, actual process interruption and retained six-cut campaign. */
14export async function qualifyPortableHostSlots(directory: string, source: string, project: string, olderBinary?: string) {
15 await mkdir(directory, { mode: 0o700 })
16 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
17 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
18 assert.equal(await hash(binary), sourceSha256)
19 const olderSha256 = olderBinary ? await hash(olderBinary) : null
20 const built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer())
21 const revision = await runBoundedHostProcess(['git', '-C', project, 'rev-parse', 'HEAD'], { timeoutMs: 10000, stdoutBytes: 1024, stderrBytes: 4096, label: 'Qualification source revision' })
22 assert.equal(revision.exitCode, 0)
23 const sourceRevision = revision.stdout.trim(), repo = join(directory, 'repository')
24 const signed = await repository(repo, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
25 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store')
26 const results: { command: string, binarySha256: string, exitCode: number, stdout: string, stderr: string }[] = []
27 async function run(args: string[], expected = 0, executable = binary) {
28 const result = await runBoundedHostProcess([executable, ...args], { cwd: directory, timeoutMs: 30000, stdoutBytes: 2_000_000, stderrBytes: 65536, label: 'Portable host slot command' })
29 results.push({ command: args[0]!, binarySha256: executable === binary ? sourceSha256 : olderSha256!, ...result })
30 assert.equal(result.exitCode, expected)
31 if (expected) { assert.equal(result.stdout, ''); assert.equal(result.stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') }
32 else assert.equal(result.stderr, '')
33 return result.stdout ? JSON.parse(result.stdout) : undefined
34 }
35 try {
36 const enrolled = await run(['updates:enroll', '--trusted-root', signed.rootPath, '--output', enrollment, '--target', target, '--platform', 'host-neutral-bench-v1', '--root-sha256', signed.rootSha256, '--minimum-release-sequence', '7'])
37 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
38 const created = await run(['updates:store-init', ...flags, '--output', store])
39 const pinned = [...flags, '--store-sha256', created.storeSha256]
40 const current = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']
41 const prepared = await run(['updates:reserve', store, ...pinned, ...current])
42 const first = await run(['updates:trial', store, ...pinned, ...current, '--project', project, '--candidate', prepared.candidate.id])
43 assert.equal(first.stateFormat, 3); assert.equal(first.knownGood.health.cleanProcessExit, true)
44 assert.equal(first.knownGood.description.sha256, signed.imageHash); assert.equal(first.knownGood.currentTrustValidated, false)
45 assert.equal(first.installed.id, first.knownGood.id); assert.equal(first.physicalDeploymentAllowed, false)
46 assert.deepEqual(await run(['updates:slots', store, ...pinned]), first)
47 if (olderBinary) {
48 const before = [await hash(join(store, 'state.sqlite')), await hash(join(store, 'writer.sqlite'))]
49 await run(['updates:store', store, ...pinned], 1, olderBinary)
50 assert.deepEqual([await hash(join(store, 'state.sqlite')), await hash(join(store, 'writer.sqlite'))], before)
51 assert.equal(await hash(olderBinary), olderSha256)
52 }
53 const rollback = await run(['updates:rollback', store, ...pinned, ...current, '--project', project])
54 assert.notEqual(rollback.knownGood.health.bootId, first.knownGood.health.bootId)
55 assert.notEqual(rollback.knownGood.health.sessionId, first.knownGood.health.sessionId)
56 assert.equal(rollback.knownGood.candidateId, first.knownGood.candidateId)
57 const retainedImage = join(store, 'host-slots', rollback.knownGood.id, 'image.bin'), savedImage = join(directory, 'retained-good-image.bin')
58 await rename(retainedImage, savedImage)
59 await run(['updates:rollback', store, ...pinned, ...current, '--project', project], 1)
60 const missing = await run(['updates:slots', store, ...pinned])
61 assert.equal(missing.knownGood.id, rollback.knownGood.id); assert.equal(missing.knownGood.integrity, 'mismatch')
62 assert.equal(missing.store.releaseFloor, 7); assert.equal(missing.store.attempt.phase, 'maintenance')
63 await run(['updates:recover', store, ...pinned])
64 await rename(savedImage, retainedImage)
65 const next = await run(['updates:reserve', store, ...pinned, ...current])
66 const input = join(directory, 'interruption.json')
67 await writeFile(input, JSON.stringify({ reference: { directory: store, enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }, source: repo, project, candidateId: next.candidate.id, cut: 'slot-selected', systemClock: true }), { mode: 0o600 })
68 // Source-side worker owns the process cut. Every maintenance decision below goes through the copied CLI.
69 const worker = Bun.spawn([process.execPath, join(import.meta.dir, 'host-slot-interruptions.ts'), 'hold', input], { cwd: directory, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', detached: true })
70 const reader = worker.stdout.getReader(), timer = setTimeout(() => worker.kill('SIGKILL'), 15000)
71 let output = '', ready = false
72 try {
73 while (!output.includes('\n')) {
74 const chunk = await reader.read()
75 assert.equal(chunk.done, false)
76 output += new TextDecoder().decode(chunk.value)
77 assert.ok(output.length <= 4096)
78 }
79 const event = JSON.parse(output.trim()); assert.equal(event.ready, true); assert.equal(event.phase, 'slot-selected'); ready = true
80 process.kill(-worker.pid, 'SIGKILL'); assert.notEqual(await worker.exited, 0); assert.equal(worker.signalCode, 'SIGKILL')
81 }
82 finally {
83 clearTimeout(timer)
84 try { process.kill(-worker.pid, 'SIGKILL') }
85 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
86 await worker.exited; await reader.cancel(); reader.releaseLock()
87 await writeFile(join(directory, 'worker.stdout'), output)
88 await writeFile(join(directory, 'worker.stderr'), await new Response(worker.stderr).text())
89 await writeFile(join(directory, 'worker.json'), JSON.stringify({ ready, exitCode: worker.exitCode, signal: worker.signalCode }))
90 }
91 const interrupted = await run(['updates:slots', store, ...pinned])
92 assert.equal(interrupted.trial.phase, 'selected'); assert.equal(interrupted.store.attempt.phase, 'pending')
93 assert.equal(interrupted.knownGood.id, rollback.knownGood.id)
94 await run(['updates:abandon', store, ...pinned, '--trial', interrupted.trial.id], 1)
95 await run(['updates:recover', store, ...pinned])
96 await run(['updates:abandon', store, ...pinned, '--trial', '0'.repeat(32)], 1)
97 const abandoned = await run(['updates:abandon', store, ...pinned, '--trial', interrupted.trial.id])
98 assert.equal(abandoned.trial.phase, 'abandoned'); assert.deepEqual(abandoned.knownGood, rollback.knownGood)
99 const fresh = await run(['updates:trial', store, ...pinned, ...current, '--project', project, '--candidate', next.candidate.id])
100 assert.equal(fresh.knownGood.candidateId, next.candidate.id); assert.notEqual(fresh.knownGood.id, abandoned.trial.id)
101 assert.notEqual(fresh.knownGood.health.bootId, rollback.knownGood.health.bootId)
102 assert.notEqual(fresh.knownGood.health.sessionId, rollback.knownGood.health.sessionId)
103 assert.deepEqual(await readFile(retainedImage), Buffer.from(image))
104 const cuts = await qualifyHostSlotInterruptions(join(directory, 'six-cuts'), project)
105 assert.equal(cuts.length, 6)
106 assert.equal(await hash(source), sourceSha256); assert.equal(await hash(binary), sourceSha256)
107 return { evidence: 'copied-standalone-host-slots-v1', sourceRevision, sourceSha256, olderSha256, operations: results.length, actualProcessCuts: 7, physicalDeploymentAllowed: false }
108 }
109 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-host-slots-v1', sourceRevision, sourceSha256, olderSha256, results, physicalDeploymentAllowed: false }, null, 2)) }
110}
111if (import.meta.main) {
112 if (process.argv.length !== 5 && process.argv.length !== 6) throw new Error('Use qualify-host-slots.ts NEW_DIRECTORY COMPILED_BINARY SOURCE_PROJECT [OLDER_BINARY]')
113 console.log(JSON.stringify(await qualifyPortableHostSlots(resolve(process.argv[2]!), resolve(process.argv[3]!), resolve(process.argv[4]!), process.argv[5] ? resolve(process.argv[5]) : undefined)))
114}
tests/fixtures/updates/repository.tsmodified+31-4
Changes to tests/fixtures/updates/repository.ts
@@ -18,8 +18,8 @@ export function signer(nonCanonical = false) {
1818 return { key, sign: (data: Buffer) => new Signature({ keyID: key.keyID, sig: sign(null, data, pair.privateKey).toString('hex') }) }
1919}
2020
21/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
22export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, consistentSnapshot?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
21/** Throwaway keys stay in memory. The default text payload must never be executed or flashed. */
22export async function repository(directory: string, options: { image?: Uint8Array, custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, consistentSnapshot?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
2323 await mkdir(join(directory, 'metadata'), { recursive: true })
2424 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
2525 const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() }
@@ -38,7 +38,7 @@ export async function repository(directory: string, options: { custom?: Record<s
3838 rotated.sign(nextKey.sign)
3939 await writeFile(join(directory, 'metadata/2.root.json'), JSON.stringify(rotated.toJSON()))
4040 }
41 const image = Buffer.from('test payload only, never execute or flash\n')
41 const image = options.image ? Buffer.from(options.image) : Buffer.from('test payload only, never execute or flash\n')
4242 const imageHash = digest(image)
4343 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom }
4444 const targets = new Metadata(new Targets({ ...fields, ...options.metadataFields?.targets, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } }))
@@ -58,5 +58,32 @@ export async function repository(directory: string, options: { custom?: Record<s
5858 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/targets.json' : 'metadata/1.targets.json'), targetsBytes)
5959 const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${imageHash}.pico2-bench.bin`)
6060 await writeFile(imagePath, image)
61 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath }
61 let publishedTargetsSigner = keys.targets, publishedRootVersion = root.version
62 async function publish(update: { image: Uint8Array, version: number, custom?: Record<string, string | number | boolean>, rotateTargets?: boolean }) {
63 if (update.rotateTargets) {
64 if (options.rotate) throw new Error('Fixture target rotation requires its original root signer')
65 publishedTargetsSigner = signer()
66 const nextRoot = new Root({ ...fields, version: ++publishedRootVersion, consistentSnapshot: options.consistentSnapshot ?? true })
67 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) nextRoot.addKey(role === 'targets' ? publishedTargetsSigner.key : keys[role].key, role)
68 const metadata = new Metadata(nextRoot)
69 metadata.sign(keys.root.sign)
70 await writeFile(join(directory, `metadata/${publishedRootVersion}.root.json`), JSON.stringify(metadata.toJSON()))
71 }
72 const bytes = Buffer.from(update.image), hash = digest(bytes), revision = { ...fields, version: update.version }
73 const nextTargets = new Metadata(new Targets({ ...revision, targets: { [target]: new TargetFile({ path: target, length: bytes.length, hashes: { sha256: hash }, unrecognizedFields: { custom: { drone: { ...custom, ...update.custom } } } }) } }))
74 nextTargets.sign(publishedTargetsSigner.sign)
75 const targetsBytes = Buffer.from(JSON.stringify(nextTargets.toJSON()))
76 const nextSnapshot = new Metadata(new Snapshot({ ...revision, meta: { 'targets.json': new MetaFile({ version: update.version, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } }))
77 nextSnapshot.sign(keys.snapshot.sign)
78 const snapshotBytes = Buffer.from(JSON.stringify(nextSnapshot.toJSON()))
79 const nextTimestamp = new Metadata(new Timestamp({ ...revision, snapshotMeta: new MetaFile({ version: update.version, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) }))
80 nextTimestamp.sign(keys.timestamp.sign)
81 const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${hash}.pico2-bench.bin`)
82 await writeFile(imagePath, bytes)
83 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/targets.json' : `metadata/${update.version}.targets.json`), targetsBytes)
84 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/snapshot.json' : `metadata/${update.version}.snapshot.json`), snapshotBytes)
85 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(nextTimestamp.toJSON()))
86 return { image: bytes, imageHash: hash, imagePath }
87 }
88 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath, publish }
6289}
tests/unit/public-safety-purge.test.tsmodified+1-0
Changes to tests/unit/public-safety-purge.test.ts
@@ -71,6 +71,7 @@ test('accepted purge removes more than one hundred review leaves and audits in b
7171 try { [code, stdout, stderr] = await Promise.all([running.exited, new Response(running.stdout).text(), new Response(running.stderr).text()]) }
7272 finally { clearTimeout(timer) }
7373 expect(code, `${stdout}\n${stderr}`).toBe(0); expect(stdout).toContain('PASS actual137review FK leaves')
74 expect(stdout).toContain('PASS controlled setup crosses real60000ms expiry before its last review and refuses exact-expiry review before genuinely due purge')
7475 }
7576 finally { if (child && child.exitCode === null) { child.kill('SIGKILL'); await child.exited }; await rm(root, { recursive: true, force: true }) }
7677}, 180000)
tests/unit/runtime.test.tsmodified+59-2
Changes to tests/unit/runtime.test.ts
@@ -9,8 +9,10 @@ import { buildRuntime } from '../../resources/functions/drone/runtime/build'
99import { openRuntimeHarness } from '../../resources/functions/drone/runtime/harness'
1010import { runRuntimeBench, runtimeBenchFaults } from '../../resources/functions/drone/runtime/bench'
1111import { inspectRuntimeCapture } from '../../resources/functions/drone/runtime/capture'
12import { runBoundedHostProcess } from '../../resources/functions/host/process'
1213
1314const statusFrame = (overrides: Partial<RuntimeFrame> = {}): RuntimeFrame => ({ ...commandFrame('hello', 1, 12n, 77n), kind: 'status', sampleTimeUs: 1000n, ticks: 1, sample: -999, flags: 67, stateBytes: 88, ...overrides })
15const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
1416
1517describe('owned runtime wire boundary', () => {
1618 test('uses independent Python/zlib little-endian fixtures and the standard CRC32 vector', () => {
@@ -87,6 +89,13 @@ async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>)
8789 try { await run(harness) }
8890 finally { await harness.close() }
8991}
92async function withNativeTrailer(trailer: string, run: (harness: ReturnType<typeof openRuntimeHarness>) => Promise<void>) {
93 const root = await mkdtemp(join(tmpdir(), 'drone-host-tail-')), launcher = join(root, 'launch')
94 await writeFile(launcher, `#!/bin/sh\n${shellQuote(artifact)} "$@"\n${trailer}\n`, { mode: 0o700 })
95 const harness = openRuntimeHarness(launcher)
96 try { await run(harness) }
97 finally { await Promise.allSettled([harness.close()]); await rm(root, { recursive: true, force: true }) }
98}
9099
91100describe('actual native kernel process', () => {
92101 test('separate supervised boots have fresh owned identities and a clean idempotent process exit', async () => {
@@ -122,8 +131,7 @@ describe('actual native kernel process', () => {
122131 const protocol = join(import.meta.dir, '../../resources/functions/drone/runtime/protocol.ts')
123132 await writeFile(script, `#!${process.execPath}\nimport { decodeRuntimeFrame, encodeRuntimeFrame } from ${JSON.stringify(protocol)}\nconst frame = decodeRuntimeFrame(Uint8Array.fromHex(${JSON.stringify(fixtures.frames.status)}))\nframe.bootId = BigInt('0x' + Bun.argv.at(-1)) ^ 1n\nawait Bun.stdin.stream().getReader().read()\nconsole.log(Buffer.from(encodeRuntimeFrame(frame)).toString('hex'))\nawait Bun.sleep(10000)\n`, { mode: 0o700 })
124133 // A standalone fixture must not load the application's Bun preloads.
125 const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
126 await writeFile(launcher, `#!/bin/sh\ncd ${quote(root)} || exit 1\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`, { mode: 0o700 })
134 await writeFile(launcher, `#!/bin/sh\ncd ${shellQuote(root)} || exit 1\nexec ${shellQuote(process.execPath)} ${shellQuote(script)} "$@"\n`, { mode: 0o700 })
127135 const harness = openRuntimeHarness(launcher)
128136 try {
129137 await expect(harness.exchange('tick 1000')).rejects.toThrow('boot identity')
@@ -147,6 +155,55 @@ describe('actual native kernel process', () => {
147155 expect(lease.leaseUs).toBe(100000)
148156 expect((await send(link.stop(1))).leaseUs).toBe(0)
149157 }))
158 test('unsolicited stdout after a valid final status cannot count as a clean process close', async () => withNativeTrailer("printf '\\n'", async harness => {
159 expect((await harness.exchange('tick 1000')).bootId).toBe(harness.bootId)
160 await expect(harness.close()).rejects.toThrow('unexpected output')
161 }))
162 test('stderr after a valid final status cannot count as a clean process close', async () => withNativeTrailer("printf 'owned-fixture-warning\\n' >&2", async harness => {
163 expect((await harness.exchange('tick 1000')).bootId).toBe(harness.bootId)
164 await expect(harness.close()).rejects.toThrow('stderr')
165 }))
166 test('an exited parent with inherited pipes cannot hide an incomplete close', async () => withNativeTrailer('sleep 3 &', async harness => {
167 expect((await harness.exchange('tick 1000')).bootId).toBe(harness.bootId)
168 await expect(harness.close()).rejects.toThrow('close deadline')
169 }))
170 test('the response deadline rejects even when an exited parent leaves input and output pipes inherited', async () => {
171 const root = await mkdtemp(join(tmpdir(), 'drone-host-response-')), launcher = join(root, 'launch')
172 await writeFile(launcher, '#!/bin/sh\nexec 3<&0\nsleep 3 <&3 3<&- &\n', { mode: 0o700 })
173 const harness = openRuntimeHarness(launcher)
174 try { await expect(harness.exchange('tick 1000')).rejects.toThrow('response deadline') }
175 finally { await Promise.allSettled([harness.close()]); await rm(root, { recursive: true, force: true }) }
176 })
177 test.skipIf(process.platform !== 'linux')('a kernel-confirmed full input FIFO cannot keep a write alive beyond the response deadline', async () => {
178 const root = await mkdtemp(join(tmpdir(), 'drone-host-full-input-')), executable = join(root, 'full-input')
179 let harness: ReturnType<typeof openRuntimeHarness> | undefined
180 try {
181 const firmware = join(import.meta.dir, '../../firmware/runtime')
182 const compiled = await runBoundedHostProcess(['c++', '-std=c++17', '-I', join(firmware, 'include'), join(import.meta.dir, '../fixtures/runtime/full-input.cpp'), join(firmware, 'src/protocol.cpp'), '-o', executable], { timeoutMs: 10000, stdoutBytes: 65536, stderrBytes: 65536, label: 'Native full-input fixture' })
183 expect(compiled.exitCode).toBe(0)
184 harness = openRuntimeHarness(executable)
185 const first = await harness.exchange('x'.repeat(127)), capacity = first.stateBytes, pid = first.sequence
186 expect(capacity).toBeGreaterThanOrEqual(4096)
187 expect(capacity).toBeLessThanOrEqual(65536)
188 expect(capacity % 128).toBe(0)
189 expect(first.sample).toBe(128)
190 let queued = first.sample
191 for (let count = 1; count < capacity / 128; count++) {
192 const status = await harness.exchange('x'.repeat(127))
193 queued = status.sample
194 expect(queued).toBe((count + 1) * 128)
195 }
196 expect(queued).toBe(capacity)
197 await expect(harness.exchange('x'.repeat(127))).rejects.toThrow('response deadline')
198 await expect(harness.exchange('tick 1000')).rejects.toThrow('failed')
199 const exit = await harness.close()
200 expect(exit.exitCode).not.toBe(0)
201 expect(exit.signal).toBe('SIGKILL')
202 expect(() => process.kill(pid, 0)).toThrow()
203 expect(await harness.close()).toEqual(exit)
204 }
205 finally { if (harness) await harness.close(); await rm(root, { recursive: true, force: true }) }
206 }, 20000)
150207 test('a newer sequence cannot renew authority from an old board timestamp', async () => withNative(async harness => {
151208 await harness.exchange('tick 1000')
152209 const link = createRuntimeSession({ nonce: () => 45n })
tests/unit/updates-host-policy.test.tsadded+237-0
Changes to tests/unit/updates-host-policy.test.ts
@@ -0,0 +1,237 @@
1import { expect, test } from 'bun:test'
2import { Database } from 'bun:sqlite'
3import { mkdir, mkdtemp, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { buildRuntime } from '../../resources/functions/drone/runtime/build'
7import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
8import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, rollbackRuntimeFirmwareSlot, trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
9import { repository, target } from '../fixtures/updates/repository'
10import { createHostSourceProject } from '../fixtures/updates/host-project'
11
12test('replacement policy rejects old rollback bytes and a signed neutral label cannot execute a mismatched build', async () => {
13 const root = await mkdtemp(join(tmpdir(), 'drone-host-policy-'))
14 try {
15 const project = process.cwd(), build = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer())
16 const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
17 const sourceRevision = (await new Response(git.stdout).text()).trim()
18 expect(await git.exited).toBe(0)
19 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
20 const enrollmentDirectory = join(root, 'enrollment')
21 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
22 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
23 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
24 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
25 const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
26 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id })
27 const replacement = await signed.publish({ image: Buffer.from('signed text only, never execute\n'), version: 2, custom: { releaseSequence: 9 }, rotateTargets: true })
28 await expect(rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project })).rejects.toThrow('currently authorized')
29 const denied = await inspectRuntimeFirmwareSlots(reference)
30 expect(denied.knownGood).toEqual(good.knownGood)
31 expect(denied.store.releaseFloor).toBe(9)
32 expect(denied.store.metadata.find(item => item.role === 'root')?.version).toBe(2)
33 expect(denied.store.attempt?.phase).toBe('maintenance')
34 expect(await readFile(join(directory, 'host-slots', good.knownGood!.id, 'image.bin'))).toEqual(Buffer.from(image))
35 await recoverRuntimeTrustStore(reference)
36 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
37 expect(next.candidate!.description.sha256).toBe(replacement.imageHash)
38 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: next.candidate!.id })).rejects.toThrow('does not match the actual owned neutral build')
39 const failed = await inspectRuntimeFirmwareSlots(reference)
40 expect(failed.knownGood).toEqual(good.knownGood)
41 expect(failed.trial?.phase).toBe('failed')
42 expect(failed.trial?.health).toBeNull()
43 expect(failed.installed?.id).toBe(good.installed!.id)
44 expect(failed.store.releaseFloor).toBe(9)
45 expect(failed.physicalDeploymentAllowed).toBe(false)
46 }
47 finally { await rm(root, { recursive: true, force: true }) }
48}, 30000)
49
50test('signed revision mismatch and dirty firmware cannot start a host trial or discard accepted policy', async () => {
51 const root = await mkdtemp(join(tmpdir(), 'drone-host-source-policy-'))
52 let completed = false
53 try {
54 for (const mode of ['revision', 'dirty'] as const) {
55 const scope = join(root, mode); await mkdir(scope, { mode: 0o700 })
56 const { project, sourceRevision } = await createHostSourceProject(scope)
57 const built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer())
58 const source = join(scope, 'repository')
59 const signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision: mode === 'revision' ? 'f'.repeat(40) : sourceRevision, releaseSequence: 9 } })
60 if (mode === 'dirty') {
61 const path = join(project, 'firmware/runtime/src/host.cpp')
62 await writeFile(path, `${await readFile(path, 'utf8')}\n// Deliberate uncommitted fixture change.\n`)
63 }
64 const enrollmentDirectory = join(scope, 'enrollment')
65 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
66 const directory = join(scope, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
67 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
68 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
69 const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
70 const phases: string[] = []
71 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: prepared.candidate!.id, onProgress(event) { phases.push(event.phase) } })).rejects.toThrow('signed clean firmware source revision')
72 expect(phases).not.toContain('runtime-started'); expect(phases).not.toContain('runtime-input-committed')
73 const failed = await inspectRuntimeFirmwareSlots(reference)
74 expect(failed.installed).toBeNull(); expect(failed.knownGood).toBeNull()
75 expect(failed.trial?.phase).toBe('failed'); expect(failed.trial?.health).toBeNull()
76 expect(failed.store.releaseFloor).toBe(9)
77 await recoverRuntimeTrustStore(reference)
78 const recovered = await inspectRuntimeFirmwareSlots(reference)
79 expect(recovered.trial).toEqual(failed.trial)
80 expect(recovered.knownGood).toBeNull(); expect(recovered.store.releaseFloor).toBe(9)
81 }
82 completed = true
83 }
84 finally {
85 if (completed) await rm(root, { recursive: true, force: true })
86 else console.error(`Failed host source-policy fixture retained at ${root}`)
87 }
88}, 40000)
89
90test('actual CMake output overflow retains accepted policy and needs fresh execution after recovery', async () => {
91 const root = await mkdtemp(join(tmpdir(), 'drone-host-build-output-'))
92 let completed = false
93 try {
94 const { project, sourceRevision } = await createHostSourceProject(root, { fault: 'configure-output' })
95 const built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer())
96 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision, releaseSequence: 9 } })
97 const enrollmentDirectory = join(root, 'enrollment')
98 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
99 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
100 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
101 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
102 const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
103 const flag = join(built.directory, 'drone-output-overflow-fixture'); await writeFile(flag, 'explicit private output fault\n')
104 const phases: string[] = []
105 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: candidate.candidate!.id, onProgress(event) { phases.push(event.phase) } })).rejects.toThrow('stdout exceeds byte limit')
106 expect(phases).not.toContain('runtime-started')
107 const failed = await inspectRuntimeFirmwareSlots(reference)
108 expect(failed.knownGood).toBeNull(); expect(failed.installed).toBeNull()
109 expect(failed.trial?.phase).toBe('failed'); expect(failed.trial?.health).toBeNull()
110 expect(failed.store.releaseFloor).toBe(9)
111 await recoverRuntimeTrustStore(reference)
112 expect((await inspectRuntimeFirmwareSlots(reference)).store.releaseFloor).toBe(9)
113 await unlink(flag)
114 const fresh = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: candidate.candidate!.id })
115 expect(fresh.knownGood?.candidateId).toBe(candidate.candidate!.id)
116 expect(fresh.knownGood?.id).not.toBe(failed.trial?.id)
117 expect(fresh.knownGood?.health?.cleanProcessExit).toBe(true)
118 completed = true
119 }
120 finally {
121 if (completed) await rm(root, { recursive: true, force: true })
122 else console.error(`Failed bounded-build fixture retained at ${root}`)
123 }
124}, 40000)
125
126test('phase and reservation changes cannot promote a slot even after actual healthy execution', async () => {
127 const root = await mkdtemp(join(tmpdir(), 'drone-host-transition-policy-'))
128 let completed = false
129 try {
130 const project = process.cwd(), built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer())
131 const revision = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
132 const sourceRevision = (await new Response(revision.stdout).text()).trim()
133 expect(await revision.exited).toBe(0)
134 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
135 const enrollmentDirectory = join(root, 'enrollment')
136 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
137 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
138 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
139 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
140 const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
141 const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id })
142 await signed.publish({ image, version: 2, custom: { releaseSequence: 9 } })
143 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
144 for (const mode of ['selected', 'running', 'reservation'] as const) {
145 const phases: string[] = []
146 let changed = false
147 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: next.candidate!.id, onProgress(event) {
148 phases.push(event.phase)
149 if (event.phase !== (mode === 'selected' ? 'slot-image-written' : 'final-clock-committed')) return
150 // Actual private SQLite fault between committed phases, not a mocked transition result.
151 const db = new Database(join(directory, 'state.sqlite'), { readwrite: true, create: false })
152 try {
153 const result = mode === 'reservation'
154 ? db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase = 'prepared'").run(next.candidate!.id)
155 : db.query("UPDATE host_slots SET phase = 'failed' WHERE phase = ?").run(mode)
156 expect(result.changes).toBe(1); changed = true
157 }
158 finally { db.close() }
159 } })).rejects.toThrow(mode === 'selected' ? 'Selected host slot changed' : mode === 'running' ? 'Running host slot changed' : 'Owned candidate reservation changed')
160 expect(changed).toBe(true)
161 if (mode === 'selected') expect(phases).not.toContain('runtime-input-committed')
162 else expect(phases).toContain('final-clock-committed')
163 const failed = await inspectRuntimeFirmwareSlots(reference)
164 expect(failed.knownGood).toEqual(good.knownGood)
165 expect(failed.trial?.phase).toBe('failed'); expect(failed.trial?.health).toBeNull()
166 expect(failed.store.releaseFloor).toBe(9)
167 await recoverRuntimeTrustStore(reference)
168 const recovered = await inspectRuntimeFirmwareSlots(reference)
169 expect(recovered.knownGood).toEqual(good.knownGood)
170 expect(recovered.trial).toEqual(failed.trial); expect(recovered.store.releaseFloor).toBe(9)
171 }
172 completed = true
173 }
174 finally {
175 if (completed) await rm(root, { recursive: true, force: true })
176 else console.error(`Failed host transition fixture retained at ${root}`)
177 }
178}, 40000)
179
180test('a final trusted clock advance survives rejected confirmation and historical recovery', async () => {
181 const root = await mkdtemp(join(tmpdir(), 'drone-host-clock-floor-'))
182 try {
183 const project = process.cwd(), build = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer())
184 const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
185 const sourceRevision = (await new Response(git.stdout).text()).trim()
186 expect(await git.exited).toBe(0)
187 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
188 const enrollmentDirectory = join(root, 'enrollment')
189 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
190 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
191 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
192 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
193 const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
194 let readings = 0
195 const advancing = { source: clock.source, read: () => new Date(++readings === 1 ? '2030-01-01T00:00:00Z' : '2036-01-01T00:00:00Z') }
196 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, candidateId: prepared.candidate!.id, clock: advancing })).rejects.toThrow('expired before confirmation')
197 const failed = await inspectRuntimeFirmwareSlots(reference)
198 expect(failed.knownGood).toBeNull()
199 expect(failed.store.clockFloorMs).toBe(Date.parse('2036-01-01T00:00:00Z'))
200 expect(failed.trial?.clockCheckedMs).toBe(failed.store.clockFloorMs)
201 expect(failed.trial?.confirmedClockMs).toBeNull()
202 expect(failed.trial?.phase).toBe('failed')
203 await recoverRuntimeTrustStore(reference)
204 expect((await inspectRuntimeFirmwareSlots(reference)).store.clockFloorMs).toBe(failed.store.clockFloorMs)
205 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, candidateId: prepared.candidate!.id, clock })).rejects.toThrow('reversed update clock')
206 }
207 finally { await rm(root, { recursive: true, force: true }) }
208}, 30000)
209
210test('a trusted build output cannot overlap protected state or change it before rejection', async () => {
211 const root = await mkdtemp(join(tmpdir(), 'drone-host-build-collision-'))
212 let completed = false
213 try {
214 const built = await buildRuntime(), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer())
215 const { project, sourceRevision } = await createHostSourceProject(root)
216 const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
217 const enrollmentDirectory = join(root, 'enrollment')
218 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
219 const buildParent = join(project, 'storage/runtime-build')
220 await mkdir(buildParent, { recursive: true, mode: 0o700 })
221 const directory = join(buildParent, 'host'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
222 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
223 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
224 const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
225 const before = await readFile(join(directory, 'state.sqlite')), writer = await readFile(join(directory, 'writer.sqlite')), names = await readdir(directory)
226 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: candidate.candidate!.id })).rejects.toThrow('build output')
227 expect(await readFile(join(directory, 'state.sqlite'))).toEqual(before)
228 expect(await readFile(join(directory, 'writer.sqlite'))).toEqual(writer)
229 expect(await readdir(directory)).toEqual(names)
230 expect(await Bun.file(join(directory, 'CMakeCache.txt')).exists()).toBe(false)
231 completed = true
232 }
233 finally {
234 if (completed) await rm(root, { recursive: true, force: true })
235 else console.error(`Failed build collision fixture retained at ${root}`)
236 }
237}, 30000)
tests/unit/updates-host-slots-cli.test.tsadded+51-0
Changes to tests/unit/updates-host-slots-cli.test.ts
@@ -0,0 +1,51 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { buildRuntime } from '../../resources/functions/drone/runtime/build'
7import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
8import { createRuntimeTrustStore, reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
9import { repository, target } from '../fixtures/updates/repository'
10
11test('native host slot commands share actual health, fresh rollback and fixed rejection semantics', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-host-slot-cli-'))
13 try {
14 const project = process.cwd(), build = await buildRuntime({ project })
15 const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
16 const sourceRevision = (await new Response(git.stdout).text()).trim()
17 expect(await git.exited).toBe(0)
18 const source = join(root, 'repository'), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer())
19 const signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
20 const enrollment = join(root, 'enrollment')
21 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollment })
22 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
23 const reference = { enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
24 const reserved = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: { source: 'fixture-system-clock', read: () => new Date() } })
25 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256]
26 const current = ['--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock', '--project', project]
27 const trial = await command(['updates:trial', directory, ...flags, ...current, '--candidate', reserved.candidate!.id]).withTimeout(20000)
28 expect(trial.exitCode).toBe(0)
29 const first = JSON.parse(trial.stdout)
30 expect(first.knownGood.candidateId).toBe(reserved.candidate!.id)
31 expect(first.knownGood.health.cleanProcessExit).toBe(true)
32 expect(first.physicalDeploymentAllowed).toBe(false)
33 const status = await command(['updates:slots', directory, ...flags]).withTimeout(20000)
34 expect(status.exitCode).toBe(0)
35 expect(JSON.parse(status.stdout)).toEqual(first)
36 const missing = await command(['updates:trial', directory, ...flags, '--repository', source, '--candidate', reserved.candidate!.id, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000)
37 expect(missing.exitCode).toBe(1)
38 expect(missing.stdout).toBe('')
39 expect(missing.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n')
40 const rollback = await command(['updates:rollback', directory, ...flags, ...current]).withTimeout(20000)
41 expect(rollback.exitCode).toBe(0)
42 const next = JSON.parse(rollback.stdout)
43 expect(next.knownGood.candidateId).toBe(first.knownGood.candidateId)
44 expect(next.knownGood.health.bootId).not.toBe(first.knownGood.health.bootId)
45 const wrong = await command(['updates:abandon', directory, ...flags, '--trial', next.knownGood.id]).withTimeout(20000)
46 expect(wrong.exitCode).toBe(1)
47 expect(wrong.stdout).toBe('')
48 expect(wrong.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n')
49 }
50 finally { await rm(root, { recursive: true, force: true }) }
51}, 120000)
tests/unit/updates-host-slots.test.tsadded+124-0
Changes to tests/unit/updates-host-slots.test.ts
@@ -0,0 +1,124 @@
1import { beforeAll, expect, test } from 'bun:test'
2import { Database } from 'bun:sqlite'
3import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { buildRuntime } from '../../resources/functions/drone/runtime/build'
7import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
8import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, rollbackRuntimeFirmwareSlot, trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
9import { repository, target } from '../fixtures/updates/repository'
10import { qualifyHostSlotInterruptions } from '../fixtures/updates/host-slot-interruptions'
11import { createHostSourceProject } from '../fixtures/updates/host-project'
12
13let image: Uint8Array, sourceRevision: string
14beforeAll(async () => {
15 const build = await buildRuntime()
16 image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer())
17 const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
18 sourceRevision = (await new Response(git.stdout).text()).trim()
19 expect(await git.exited).toBe(0)
20}, 20000)
21
22test('an owned signed host image becomes known-good only through its actual fresh runtime and clean process close', async () => {
23 const root = await mkdtemp(join(tmpdir(), 'drone-host-slot-'))
24 try {
25 const source = join(root, 'repository')
26 const signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } })
27 const enrollmentDirectory = join(root, 'enrollment')
28 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
29 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
30 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
31 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
32 const reserved = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
33 expect((await inspectRuntimeFirmwareSlots(reference)).knownGood).toBeNull()
34 const result = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, candidateId: reserved.candidate!.id, project: process.cwd() })
35 expect(result.schema).toBe('drone-runtime-host-slots-v1')
36 expect(result.stateFormat).toBe(3)
37 expect(result.knownGood?.candidateId).toBe(reserved.candidate!.id)
38 expect(result.knownGood?.description.sha256).toBe(signed.imageHash)
39 expect(result.installed?.id).toBe(result.knownGood?.id)
40 expect(result.knownGood?.phase).toBe('confirmed')
41 expect(result.knownGood?.health?.bootId).toMatch(/^[1-9][0-9]{0,19}$/)
42 expect(result.knownGood?.health?.sessionId).toMatch(/^[1-9][0-9]{0,19}$/)
43 expect(result.knownGood?.health?.ticks).toBe(100)
44 expect(result.knownGood?.health?.cleanProcessExit).toBe(true)
45 expect(result.knownGood?.health?.transcriptItems).toBeGreaterThan(200)
46 expect(result.physicalDeploymentAllowed).toBe(false)
47 expect(await inspectRuntimeFirmwareSlots(reference)).toEqual(result)
48 const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
49 const interruption = new Error('Owned observer stops before the second runtime launch')
50 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, candidateId: next.candidate!.id, project: process.cwd(), onProgress(event) {
51 if (event.phase === 'runtime-started') throw interruption
52 } })).rejects.toBe(interruption)
53 const failed = await inspectRuntimeFirmwareSlots(reference)
54 expect(failed.knownGood).toEqual(result.knownGood)
55 expect(failed.installed?.phase).toBe('failed')
56 expect(failed.installed?.id).not.toBe(result.knownGood!.id)
57 expect(failed.store.releaseFloor).toBe(7)
58 expect(failed.store.attempt?.phase).toBe('maintenance')
59 expect(await readFile(join(directory, 'host-slots', result.knownGood!.id, 'image.bin'))).toEqual(Buffer.from(image))
60 await recoverRuntimeTrustStore(reference)
61 const recovered = await inspectRuntimeFirmwareSlots(reference)
62 expect(recovered.knownGood).toEqual(result.knownGood)
63 expect(recovered.installed?.phase).toBe('failed')
64 expect(recovered.store.attempt?.phase).toBe('recovered')
65 const rollback = await rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project: process.cwd() })
66 expect(rollback.knownGood?.candidateId).toBe(result.knownGood!.candidateId)
67 expect(rollback.knownGood?.id).not.toBe(result.knownGood!.id)
68 expect(rollback.knownGood?.health?.bootId).not.toBe(result.knownGood!.health!.bootId)
69 expect(rollback.knownGood?.health?.sessionId).not.toBe(result.knownGood!.health!.sessionId)
70 expect(rollback.installed?.id).toBe(rollback.knownGood!.id)
71 expect(rollback.retainedSlots).toBe(3)
72 expect(rollback.physicalDeploymentAllowed).toBe(false)
73 const goodImage = join(directory, 'host-slots', rollback.knownGood!.id, 'image.bin')
74 await writeFile(goodImage, Buffer.alloc(image.length, 0xff))
75 await expect(rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project: process.cwd() })).rejects.toThrow('known-good integrity')
76 expect((await inspectRuntimeFirmwareSlots(reference)).knownGood!.id).toBe(rollback.knownGood!.id)
77 expect(await readFile(goodImage)).toEqual(Buffer.alloc(image.length, 0xff))
78 await writeFile(goodImage, image)
79 await recoverRuntimeTrustStore(reference)
80 // Deliberately corrupt retained state through the real file, without using it as a success oracle.
81 const db = new Database(join(directory, 'state.sqlite'), { create: false, readwrite: true })
82 try {
83 const corrupted = { ...rollback.knownGood!.health!, bootId: rollback.knownGood!.health!.bootId === '1' ? '2' : '1' }
84 db.query('UPDATE host_slots SET health = ? WHERE id = ?').run(Buffer.from(JSON.stringify(corrupted)), rollback.knownGood!.id)
85 }
86 finally { db.close() }
87 await expect(inspectRuntimeFirmwareSlots(reference)).rejects.toThrow('transcript')
88 }
89 finally { await rm(root, { recursive: true, force: true }) }
90}, 30000)
91
92test('actual host trial process cuts retain prior good and need explicit recovery without inventing confirmation', async () => {
93 const root = await mkdtemp(join(tmpdir(), 'drone-host-slot-cuts-'))
94 let completed = false
95 try {
96 const result = await qualifyHostSlotInterruptions(join(root, 'campaign'), process.cwd())
97 expect(result).toHaveLength(6)
98 expect(result.every(item => item.signal === 'SIGKILL' && item.contender === 17 && item.recoveredWithoutPromotion && item.freshRollback)).toBe(true)
99 completed = true
100 }
101 finally {
102 if (completed) await rm(root, { recursive: true, force: true })
103 else console.error(`Failed host slot campaign retained at ${root}`)
104 }
105}, 90000)
106
107test('a real signed neutral kernel with a wrong-session stop reply cannot confirm a host slot', async () => {
108 const root = await mkdtemp(join(tmpdir(), 'drone-host-stop-session-'))
109 try {
110 const { project, sourceRevision: revision } = await createHostSourceProject(root, { fault: 'stop-session' })
111 const build = await buildRuntime({ project })
112 const source = join(root, 'repository'), bytes = new Uint8Array(await Bun.file(build.artifact).arrayBuffer())
113 const signed = await repository(source, { image: bytes, custom: { platform: 'host-neutral-bench-v1', sourceRevision: revision } })
114 const enrollmentDirectory = join(root, 'enrollment')
115 const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory })
116 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
117 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
118 const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
119 const reserved = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })
120 await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, candidateId: reserved.candidate!.id, project })).rejects.toThrow('stop')
121 expect((await inspectRuntimeFirmwareSlots(reference)).knownGood).toBeNull()
122 }
123 finally { await rm(root, { recursive: true, force: true }) }
124}, 30000)