carlos / platform Public

merge main (install-console-upgrade) into moves-offline-fixes; internal/host envelope re-pinned

claude agent, operated by paul@keymail.dev f19756efe29af76847378e82b10847d572da7b0e parent 574cf51parent 2185035
11 files changed, +977 −24
  • docs/site/cli.md +2 −0
  • docs/superpowers/notes/census-proposal-install-console-upgrade.md +50 −0
  • docs/superpowers/notes/census-proposal-moves-offline-fixes.md +1 −0
  • internal/host/activation_artifacts_linux.go +280 −19
  • internal/host/activation_artifacts_linux_test.go +124 −1
  • internal/host/activation_command.go +1 −1
  • internal/host/activation_command_linux.go +20 −2
  • internal/host/activation_console_upgrade_rootrunner_linux_test.go +471 −0
  • internal/provision/plan14_root_reads_test.go +14 −0
  • internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv +13 −0
  • internal/provision/testdata/plan14-root-reads-moves-offline-fixes.tsv +1 −1

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	327.208s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.839s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.495s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.716s
ok  	github.com/carlosframework/platform/internal/activator	250.844s
ok  	github.com/carlosframework/platform/internal/agentsock	0.018s
ok  	github.com/carlosframework/platform/internal/alerting	0.007s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.496s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.135s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.720s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.016s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.703s
ok  	github.com/carlosframework/platform/internal/changelog	0.328s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.807s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	419.562s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.554s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.517s
ok  	github.com/carlosframework/platform/internal/doorkey	0.008s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	45.402s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.180s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.018s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.187s
ok  	github.com/carlosframework/platform/internal/host	192.557s
ok  	github.com/carlosframework/platform/internal/hostactors	6.114s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.006s
ok  	github.com/carlosframework/platform/internal/hostbox	2.771s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.596s
ok  	github.com/carlosframework/platform/internal/hostenv	0.006s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	5.166s
ok  	github.com/carlosframework/platform/internal/hostfence	0.031s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.908s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	1.003s
ok  	github.com/carlosframework/platform/internal/hostops	1.628s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.003s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.494s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.153s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.005s
ok  	github.com/carlosframework/platform/internal/instancectl	5.270s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.763s
ok  	github.com/carlosframework/platform/internal/instancefs	0.021s
ok  	github.com/carlosframework/platform/internal/instancekey	0.002s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.022s
ok  	github.com/carlosframework/platform/internal/layout	1.589s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.002s
ok  	github.com/carlosframework/platform/internal/lease	10.076s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.379s
ok  	github.com/carlosframework/platform/internal/ledger	0.015s
ok  	github.com/carlosframework/platform/internal/mail	0.179s
ok  	github.com/carlosframework/platform/internal/maintenance	0.094s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.848s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.091s
ok  	github.com/carlosframework/platform/internal/metrics	0.007s
ok  	github.com/carlosframework/platform/internal/moves	22.043s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.805s
ok  	github.com/carlosframework/platform/internal/objstore	0.194s
ok  	github.com/carlosframework/platform/internal/ops	0.178s
ok  	github.com/carlosframework/platform/internal/park	0.222s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	94.686s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	6.011s
ok  	github.com/carlosframework/platform/internal/relay	7.238s
ok  	github.com/carlosframework/platform/internal/release	26.810s
ok  	github.com/carlosframework/platform/internal/rollout	3.787s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.005s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.058s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.583s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.596s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	12.192s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.786s
ok  	github.com/carlosframework/platform/internal/status	0.072s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.226s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.005s
ok  	github.com/carlosframework/platform/internal/usage	0.009s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.559s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49807522 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/20 20:42:13 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E057914BF61AB7, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 194ae0780..4ca942cd4 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2162,6 +2162,8 @@ Two `run` phases make the files `carlos system guard-arm` takes. `sign-policy` s
`install-console` copies the console build that the published identity names into `/opt/carlos/system/console/console` and `console.prev`, owned by root. It refuses a source whose bytes don't match the identity, and it doesn't touch `console.service`. Pass that path to `guard-arm --console`.
+To replace an installed console, add `--upgrade --from <sha256>`, where the sha256 is the console that is running now. It refuses unless that build is what the box holds, then installs the new console and keeps the old one as `console.prev`. A box upgraded this way can't be armed again with `guard-arm` until `install-console` has been run without `--upgrade`, because `guard-arm` expects both copies to be the same build.
+
After starting the guarded console, run `console-report` on its box as root. It checks the signed roster, guard, systemd process and running executable, then records that process in the existing private box report. It refuses stale box evidence and leaves the report’s timestamp unchanged. Run it again after a console restart and immediately before floor publication; the next agent report may replace the process entry.
#### carlos system journal-migrate
diff --git a/docs/superpowers/notes/census-proposal-install-console-upgrade.md b/docs/superpowers/notes/census-proposal-install-console-upgrade.md
new file mode 100644
index 000000000..1de21633c
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-install-console-upgrade.md
@@ -0,0 +1,50 @@
+# Census proposal: install-console --upgrade
+
+Status: RULED (platform-d8, 2026-10-20): census-ruling-install-console-upgrade, accepted as proposed (U1 to U4 at da0a33b7; U5 to U7 for the delta to b913bb65). Astra approved b913bb65. Branch `install-console-upgrade`, cut from main at 54ae7fb9.
+
+## What changed
+
+`claims install-console` was a first install only: an occupied slot holding another build refused, so a guard-armed box's protected console could never be replaced. platform-d8's harness found this rolling dev's console to v0.28.5. This branch adds `--upgrade --from <sha256>`. Without `--upgrade`, install-console and its output are unchanged.
+
+- **`runSystemClaims`** (changed): two new flags, `--upgrade` and `--from`. `upgradeFlagsRefused` (new, no read site) admits them only together and only for install-console. Anywhere else they refuse with errActivation, as unknown flags did before. The upgrade output adds `upgraded`, `from` and `previous_sha`.
+- **`runInstallConsole`** (changed, no read site): the identity, published-identity, build and platform checks are unchanged. A non-empty `from` calls `upgradeProtectedConsole` instead of `installProtectedConsole`.
+- **`consoleSlotSHA`** (new): `consoleSlotHolds`'s read moved here. The shape check is the same and is now applied to the opened descriptor as well (same device and inode). It returns the slot's sha256, or "" when absent. `consoleSlotHolds` keeps its behaviour and now has no read site (`@remove`).
+- **`upgradeProtectedConsole`** (new): the source is verified against the identity's console sha256 before anything is opened. The directory is checked as `consoleDir` does but never created. Under the directory's exclusive flock:
+ - `console` and `console.prev` must each be absent or a root:root 0755 regular file with one link. `console.prev`'s bytes are not checked.
+ - `console` holding the target: Already, nothing changes.
+ - `console` holding `--from`: upgrade.
+ - `console` absent and `console.prev` holding `--from`: a run cut off between its two renames; this run completes it.
+ - Anything else refuses and changes nothing.
+
+ The upgrade writes `console.next` (exclusive, 0755, fsync), renames `console` to `console.prev`, renames `console.next` to `console`, fsyncs the directory, and reads both slots back through `hostfloor.OpenProtected`. A leftover `console.next` that is a regular file is removed first; anything else at that name refuses.
+- **`writeConsoleTemp`** (new): the exclusive no-follow create of `console.next`, chmod 0755, root:root and one link on the descriptor, fsync.
+
+## Root-read census (TestPlan14RootReadCensus)
+
+Overlay `internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv`, loaded last, after `plan14-root-reads-armed-box-updates.tsv`. No accepted-legacy row, so the seal stands.
+
+| Unit | Executing identity | Class / tasks | Basis |
+|---|---|---|---|
+| `activation_command_linux.go:runSystemClaims` (changed) | operator CLI, uid 0 | authority G,J4 (fix1's) | the two flags, admitted only together for install-console |
+| `activation_artifacts_linux.go:consoleSlotHolds` (`@remove`) | | | its read moved into consoleSlotSHA |
+| `activation_artifacts_linux.go:consoleSlotSHA` (new) | box installer, uid 0 | authority G,J4 (consoleSlotHolds's) | absent, or root:root 0755 one-link regular file, checked on the entry and the descriptor, hashed bounded |
+| `activation_artifacts_linux.go:upgradeProtectedConsole` (new) | box installer, uid 0 (`claims install-console --upgrade`) | authority G,J4 (caller's) | as above |
+| `activation_artifacts_linux.go:writeConsoleTemp` (new) | box installer, uid 0 | authority G,J4 (caller's) | exclusive create of one fixed name in the locked directory |
+
+The `internal/host` source envelope is re-pinned. No other envelope moves.
+
+No box agent, update, recovery or edge path reaches a new or changed unit. guard-exec, `hostfloor.CheckComponent` and `installProtectedConsole` are unchanged.
+
+## Record-write census (TestPlan9B8RecordWriteCensus)
+
+Unchanged and passing. The upgrade writes local files only and adds no bucket writer.
+
+## Round 2 (Astra's review of da0a33b7, MERGE: block)
+
+Same overlay and marker (`rev3=PROPOSED-install-console-upgrade`). platform-d8 accepted da0a33b7; this delta needs a new ruling.
+
+- **F1, source aliasing (MEDIUM).** An upgrade refuses a `--source` inside the console directory by path, before reading anything. Under the lock, it also refuses a source whose opened descriptor has the device and inode of `console`, `console.prev` or `console.next`, which catches a bind mount or any other path to the same file. To return that descriptor's stat, `readConsoleSource`'s read moved unchanged into `readConsoleSourceFile` (new). `readConsoleSource` wraps it for the first install and now has no read site (`@remove`).
+- **F2, Already skipped the directory fsync (MEDIUM).** A non-dry Already now fsyncs the held directory and returns any failure. A run cut off after its second rename, before its fsync, is therefore made durable by the rerun. The fsync goes through `consoleUpgradeSync`, a package variable with no read site that tests replace.
+- **F3, prefix digest (LOW).** `consoleDigest` (new) hashes a held slot through EOF. It refuses a slot over 512 MiB by fstat size and by the bytes read (`consoleCounter.Read`, new), and never hashes a prefix. `consoleSlotSHA` and the upgrade's readback use it. The first install's slot checks go through `consoleSlotSHA`, so they get the same refusal. The first install's own readback is unchanged.
+
+Rows: `consoleSlotSHA` and `upgradeProtectedConsole` are re-pinned. `readConsoleSourceFile`, `consoleDigest` and `consoleCounter.Read` are new. `readConsoleSource` gets `@remove`. All are authority G,J4, the class of the rows they come from or their caller's. The `internal/host` envelope is re-pinned. There is no legacy row, so the seal stands, and 9B8 is unchanged.
diff --git a/docs/superpowers/notes/census-proposal-moves-offline-fixes.md b/docs/superpowers/notes/census-proposal-moves-offline-fixes.md
index d5693f960..ff13f68ec 100644
--- a/docs/superpowers/notes/census-proposal-moves-offline-fixes.md
+++ b/docs/superpowers/notes/census-proposal-moves-offline-fixes.md
@@ -90,3 +90,4 @@ still shows on its own.
## Ruling (platform-1c, 2026-10-20): census-ruling-moves-offline-fixes, ACCEPTED as proposed
Accepted at d75b7e0b (F1 to F4) and re-ruled for fix round 1 at 7cd5db4d (F5 to F7). After Astra's scoped re-review, the backoff counter saturates (agent.go `pendingDirs.record`): an internal/host envelope re-pin only. The rev3 tag was rewritten to the ruling's name, so the seal moves to 826c54be…a62f.
+- After merging main 21850356 (install-console-upgrade, loaded before this overlay): internal/host envelope re-pinned to the combined tree; seal unchanged.
diff --git a/internal/host/activation_artifacts_linux.go b/internal/host/activation_artifacts_linux.go
index 2708a9d11..800bce5a1 100644
--- a/internal/host/activation_artifacts_linux.go
+++ b/internal/host/activation_artifacts_linux.go
@@ -233,10 +233,13 @@ func consoleInstallTarget(identity, published []byte, pub ed25519.PublicKey, bui
return p.Current.ReleaseTag, a.SHA256, nil
}
-// consoleInstalled is what one install-console run did.
+// consoleInstalled is what one install-console run did. An upgrade also
+// reports the build it replaced (From) and what console.prev holds after it
+// (PreviousSHA).
type consoleInstalled struct {
- Path, Previous string
- Already bool
+ Path, Previous string
+ Already bool
+ From, PreviousSHA string
}
// installProtectedConsole copies the console at source into dir as console
@@ -336,50 +339,303 @@ func installProtectedConsole(dir, source, sha string, dry bool) (consoleInstalle
// owned regular file, one link, mode 0755, holding sha (true). Anything else
// is refused.
func consoleSlotHolds(d *os.File, name, sha string) (bool, error) {
+ hash, err := consoleSlotSHA(d, name)
+ if err != nil || hash == "" {
+ return false, err
+ }
+ if hash != sha {
+ return false, errActivation
+ }
+ return true, nil
+}
+
+// consoleSlotSHA returns "" when name in d is absent, and otherwise the
+// sha256 of a root:root regular file with one link and mode 0755, checked
+// on the entry, not followed, and again on the descriptor it hashes.
+// Anything else is refused.
+func consoleSlotSHA(d *os.File, name string) (string, error) {
var st unix.Stat_t
err := unix.Fstatat(int(d.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW)
if err == unix.ENOENT {
- return false, nil
+ return "", nil
}
if err != nil {
- return false, err
+ return "", err
}
- if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 || st.Mode&0o7777 != 0o755 {
- return false, errActivation
+ shape := func(st *unix.Stat_t) bool {
+ return st.Mode&unix.S_IFMT == unix.S_IFREG && st.Nlink == 1 && st.Uid == 0 && st.Gid == 0 && st.Mode&0o7777 == 0o755
+ }
+ if !shape(&st) {
+ return "", errActivation
}
f, err := safefile.OpenFileAt(int(d.Fd()), name, os.O_RDONLY, 0)
if err != nil {
- return false, err
+ return "", err
}
defer f.Close()
- hash, err := hostfloor.DigestReader(io.LimitReader(f, consoleMaxBytes+1))
+ var held unix.Stat_t
+ if err = unix.Fstat(int(f.Fd()), &held); err != nil {
+ return "", err
+ }
+ if !shape(&held) || held.Dev != st.Dev || held.Ino != st.Ino {
+ return "", errActivation
+ }
+ return consoleDigest(f)
+}
+
+// consoleDigest hashes a console slot through EOF. A file larger than
+// consoleMaxBytes is refused, by its size and by the bytes read, never
+// hashed by its prefix.
+func consoleDigest(f *os.File) (string, error) {
+ var st unix.Stat_t
+ if err := unix.Fstat(int(f.Fd()), &st); err != nil {
+ return "", err
+ }
+ if st.Size < 0 || st.Size > consoleMaxBytes {
+ return "", errActivation
+ }
+ r := &consoleCounter{r: io.LimitReader(f, consoleMaxBytes+1)}
+ hash, err := hostfloor.DigestReader(r)
if err != nil {
- return false, err
+ return "", err
}
- if hash != sha {
- return false, errActivation
+ if r.n > consoleMaxBytes {
+ return "", errActivation
}
- return true, nil
+ return hash, nil
+}
+
+// consoleCounter counts the bytes read through it.
+type consoleCounter struct {
+ r io.Reader
+ n int64
+}
+
+func (c *consoleCounter) Read(p []byte) (int, error) {
+ n, err := c.r.Read(p)
+ c.n += int64(n)
+ return n, err
+}
+
+// consoleUpgradeSync fsyncs the console directory at the end of an upgrade
+// and before an Already result. Tests replace it to see that it ran.
+var consoleUpgradeSync = func(fd int) error { return unix.Fsync(fd) }
+
+// consoleUpgradeTemp is the one name an upgrade writes the new console to
+// before renaming it into place. Only an upgrade creates it, under the
+// directory's exclusive lock, and a rerun removes a leftover one.
+const consoleUpgradeTemp = "console.next"
+
+// upgradeProtectedConsole replaces a guarded box's protected console with
+// the build at source, which must hash to sha, for claims install-console
+// --upgrade. The source and root checks are installProtectedConsole's, and
+// the directory must already exist: an upgrade never creates it.
+//
+// Under the directory's exclusive lock, console and console.prev must each
+// be absent or a root:root regular file, mode 0755, one link; anything else
+// refuses. console.prev's bytes are not checked, because they are replaced.
+// - console holding sha: Already, and nothing changes, so a rerun after
+// success is a no-op.
+// - console holding from: the upgrade.
+// - console absent and console.prev holding from: a run cut off between
+// its two renames; this run completes it.
+// - anything else refuses and changes nothing.
+//
+// A source that is, or is inside, the console directory refuses, by its
+// path and by the device and inode of the descriptor it was read from
+// against console, console.prev and console.next: an upgrade must never
+// consume its own source.
+//
+// A non-dry Already fsyncs the directory first, so a rerun after a run cut
+// off before its final fsync makes that run's renames durable.
+//
+// The upgrade writes the build to console.next (exclusive create, 0755,
+// fsync), renames console to console.prev, replacing the old .prev,
+// renames console.next to console, and fsyncs the directory. A console.next
+// left by a run cut off before its renames is removed first: only this
+// function creates that name, under the lock, in a directory only root can
+// write, and its bytes are never used. A console.next that is not a regular
+// file refuses. Both slots are then read back as guard-arm reads them
+// (hostfloor.OpenProtected): console must hash to sha and console.prev to
+// from. The dry run makes every check and writes nothing.
+func upgradeProtectedConsole(dir, source, sha, from string, dry bool) (consoleInstalled, error) {
+ got := consoleInstalled{Path: filepath.Join(dir, "console"), Previous: filepath.Join(dir, "console.prev"), From: from}
+ if len(sha) != 64 || !isLowerHex(from, 64) || !filepath.IsAbs(source) || filepath.Clean(source) != source {
+ return got, errActivation
+ }
+ if source == dir || strings.HasPrefix(source, dir+"/") {
+ return got, errActivation
+ }
+ if os.Geteuid() != 0 {
+ return got, errActivation
+ }
+ body, src, err := readConsoleSourceFile(source, sha)
+ if err != nil {
+ return got, err
+ }
+ d, err := consoleDir(dir, false)
+ if err != nil {
+ return got, err
+ }
+ defer d.Close()
+ if !dry {
+ if err = unix.Flock(int(d.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ return got, err
+ }
+ }
+ for _, name := range []string{"console", "console.prev", consoleUpgradeTemp} {
+ var st unix.Stat_t
+ err = unix.Fstatat(int(d.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW)
+ if err == nil && st.Dev == src.Dev && st.Ino == src.Ino {
+ return got, errActivation
+ }
+ if err != nil && err != unix.ENOENT {
+ return got, err
+ }
+ }
+ current, err := consoleSlotSHA(d, "console")
+ if err != nil {
+ return got, err
+ }
+ previous, err := consoleSlotSHA(d, "console.prev")
+ if err != nil {
+ return got, err
+ }
+ if current == sha {
+ got.Already, got.PreviousSHA = true, previous
+ if !dry {
+ if err = consoleUpgradeSync(int(d.Fd())); err != nil {
+ return got, err
+ }
+ }
+ return got, nil
+ }
+ if current != from && (current != "" || previous != from) {
+ return got, errActivation
+ }
+ got.PreviousSHA = from
+ var st unix.Stat_t
+ switch err = unix.Fstatat(int(d.Fd()), consoleUpgradeTemp, &st, unix.AT_SYMLINK_NOFOLLOW); {
+ case err == unix.ENOENT:
+ case err != nil:
+ return got, err
+ case st.Mode&unix.S_IFMT != unix.S_IFREG:
+ return got, errActivation
+ case !dry:
+ if err = unix.Unlinkat(int(d.Fd()), consoleUpgradeTemp, 0); err != nil {
+ return got, err
+ }
+ }
+ if dry {
+ return got, nil
+ }
+ if err = writeConsoleTemp(d, body); err != nil {
+ unix.Unlinkat(int(d.Fd()), consoleUpgradeTemp, 0)
+ return got, err
+ }
+ if current != "" {
+ if err = unix.Renameat(int(d.Fd()), "console", int(d.Fd()), "console.prev"); err != nil {
+ return got, err
+ }
+ }
+ if err = unix.Renameat(int(d.Fd()), consoleUpgradeTemp, int(d.Fd()), "console"); err != nil {
+ return got, err
+ }
+ if err = consoleUpgradeSync(int(d.Fd())); err != nil {
+ return got, err
+ }
+ for path, want := range map[string]string{got.Path: sha, got.Previous: from} {
+ f, err := hostfloor.OpenProtected(path)
+ if err != nil {
+ return got, err
+ }
+ err = unix.Fstat(int(f.Fd()), &st)
+ var hash string
+ if err == nil {
+ hash, err = consoleDigest(f)
+ }
+ f.Close()
+ if err != nil {
+ return got, err
+ }
+ if hash != want || st.Mode&0o7777 != 0o755 || st.Nlink != 1 || st.Uid != 0 || st.Gid != 0 {
+ return got, errActivation
+ }
+ }
+ return got, nil
+}
+
+// writeConsoleTemp creates consoleUpgradeTemp in d exclusively, never
+// following a link, writes body, sets mode 0755 whatever the umask, checks
+// it is root's and fsyncs it.
+func writeConsoleTemp(d *os.File, body []byte) error {
+ f, err := safefile.OpenFileAt(int(d.Fd()), consoleUpgradeTemp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if _, err = f.Write(body); err != nil {
+ return err
+ }
+ if err = f.Chmod(0o755); err != nil {
+ return err
+ }
+ var st unix.Stat_t
+ if err = unix.Fstat(int(f.Fd()), &st); err != nil {
+ return err
+ }
+ if st.Uid != 0 || st.Gid != 0 || st.Nlink != 1 {
+ return errActivation
+ }
+ if err = f.Sync(); err != nil {
+ return err
+ }
+ return f.Close()
+}
+
+// isLowerHex reports whether s is exactly n lower-case hex digits.
+func isLowerHex(s string, n int) bool {
+ if len(s) != n {
+ return false
+ }
+ for _, c := range s {
+ if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
+ return false
+ }
+ }
+ return true
}
// readConsoleSource reads the source console, no symlink followed on its
// path, a regular file with one link, at most consoleMaxBytes, and refuses
// it unless it hashes to sha.
func readConsoleSource(source, sha string) ([]byte, error) {
+ body, _, err := readConsoleSourceFile(source, sha)
+ return body, err
+}
+
+// readConsoleSourceFile is readConsoleSource, also returning the stat of
+// the descriptor the bytes were read from, so an upgrade can refuse a
+// source that is one of its own slots.
+func readConsoleSourceFile(source, sha string) ([]byte, unix.Stat_t, error) {
+ var st unix.Stat_t
f, err := safefile.OpenFile(source, os.O_RDONLY, 0)
if err != nil {
- return nil, err
+ return nil, st, err
}
defer f.Close()
+ if err = unix.Fstat(int(f.Fd()), &st); err != nil {
+ return nil, st, err
+ }
body, err := io.ReadAll(io.LimitReader(f, consoleMaxBytes+1))
if err != nil {
- return nil, err
+ return nil, st, err
}
sum := sha256.Sum256(body)
if len(body) > consoleMaxBytes || hex.EncodeToString(sum[:]) != sha {
- return nil, errActivation
+ return nil, st, errActivation
}
- return body, nil
+ return body, st, nil
}
// consoleDir opens dir component by component from /, never following a
@@ -439,8 +695,9 @@ func consoleDir(dir string, create bool) (*os.File, error) {
// runInstallConsole is install-console's work after its flags: the staged
// identity checked against the published one in store, then the protected
-// copies.
-func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.PublicKey, identityPath, source, dir, build, platform string, dry bool) (string, string, consoleInstalled, error) {
+// copies. A non-empty from is --upgrade --from: the installed console must
+// hash to it, and it is replaced.
+func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.PublicKey, identityPath, source, dir, build, platform, from string, dry bool) (string, string, consoleInstalled, error) {
identity, err := activationRead(identityPath, false)
if err != nil {
return "", "", consoleInstalled{}, err
@@ -461,6 +718,10 @@ func runInstallConsole(ctx context.Context, store release.Store, pub ed25519.Pub
if err != nil {
return "", "", consoleInstalled{}, err
}
+ if from != "" {
+ got, err := upgradeProtectedConsole(dir, source, sha, from, dry)
+ return tag, sha, got, err
+ }
got, err := installProtectedConsole(dir, source, sha, dry)
return tag, sha, got, err
}
diff --git a/internal/host/activation_artifacts_linux_test.go b/internal/host/activation_artifacts_linux_test.go
index 2ff4b715d..1327eb338 100644
--- a/internal/host/activation_artifacts_linux_test.go
+++ b/internal/host/activation_artifacts_linux_test.go
@@ -12,6 +12,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "flag"
"os"
"path/filepath"
"strings"
@@ -232,6 +233,128 @@ func TestInstallConsoleRefusesWithoutRoot(t *testing.T) {
}
}
+func TestInstallConsoleUpgradeRefusesWithoutRoot(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("runs as non-root")
+ }
+ body := []byte("console build")
+ src := filepath.Join(t.TempDir(), "console")
+ if err := os.WriteFile(src, body, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ dir := filepath.Join(t.TempDir(), "protected")
+ for _, dry := range []bool{true, false} {
+ if _, err := upgradeProtectedConsole(dir, src, artifactSHA(body), artifactSHA([]byte("old")), dry); err == nil {
+ t.Fatal("upgraded without root")
+ }
+ }
+ if _, err := os.Lstat(dir); !os.IsNotExist(err) {
+ t.Fatal("refusal created the directory", err)
+ }
+}
+
+// consoleDigest refuses more than consoleMaxBytes, by size or by bytes read
+// (a pipe reports size 0), and an upgrade refuses a source path inside the
+// console directory before reading anything.
+func TestConsoleDigestRefusesOversized(t *testing.T) {
+ big := filepath.Join(t.TempDir(), "big")
+ if err := os.WriteFile(big, nil, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Truncate(big, consoleMaxBytes+2); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Open(big)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if _, err := consoleDigest(f); err == nil {
+ t.Fatal("hashed an oversized file")
+ }
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ go func() {
+ buf := make([]byte, 1<<20)
+ for n := int64(0); n <= consoleMaxBytes; n += int64(len(buf)) {
+ if _, err := w.Write(buf); err != nil {
+ break
+ }
+ }
+ w.Close()
+ }()
+ if _, err := consoleDigest(r); err == nil {
+ t.Fatal("hashed an oversized stream")
+ }
+ small := filepath.Join(t.TempDir(), "small")
+ if err := os.WriteFile(small, []byte("console build"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ s, err := os.Open(small)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ if got, err := consoleDigest(s); err != nil || got != artifactSHA([]byte("console build")) {
+ t.Fatal(got, err)
+ }
+}
+
+// --upgrade and --from are install-console's, and come together; anywhere
+// else they are refused before any input is read, as unknown flags were.
+func TestClaimsUpgradeFlagsBelongToInstallConsole(t *testing.T) {
+ sha := artifactSHA([]byte("old"))
+ for _, args := range [][]string{
+ {"install-console", "--upgrade"},
+ {"install-console", "--from", sha},
+ {"install-console", "--upgrade=false", "--from", sha},
+ {"install-console", "--upgrade", "--from", ""},
+ {"install-console", "--upgrade=false"},
+ {"inspect", "--upgrade", "--from", sha},
+ {"console-report", "--from", sha},
+ } {
+ var out bytes.Buffer
+ if err := runSystemClaims(args, &out); !errors.Is(err, errActivation) || out.Len() != 0 {
+ t.Fatal("accepted", args, err, out.String())
+ }
+ }
+ parse := func(args ...string) *flag.FlagSet {
+ fs := flag.NewFlagSet("claims", flag.ContinueOnError)
+ fs.Bool("upgrade", false, "")
+ fs.String("from", "", "")
+ fs.String("source", "", "")
+ if err := fs.Parse(args); err != nil {
+ t.Fatal(err)
+ }
+ return fs
+ }
+ for _, tc := range []struct {
+ command string
+ args []string
+ upgrade bool
+ from string
+ refused bool
+ }{
+ {"install-console", []string{"--source", "x"}, false, "", false},
+ {"inspect", nil, false, "", false},
+ {"install-console", []string{"--upgrade", "--from", sha}, true, sha, false},
+ {"install-console", []string{"--upgrade"}, true, "", true},
+ {"install-console", []string{"--from", sha}, false, sha, true},
+ {"install-console", []string{"--upgrade=false"}, false, "", true},
+ {"install-console", []string{"--upgrade", "--from", ""}, true, "", true},
+ {"inspect", []string{"--upgrade", "--from", sha}, true, sha, true},
+ {"install-control", []string{"--from", sha}, false, sha, true},
+ {"install-control", []string{"--upgrade"}, true, "", true},
+ } {
+ if got := upgradeFlagsRefused(tc.command, parse(tc.args...), tc.upgrade, tc.from); got != tc.refused {
+ t.Fatal(tc.command, tc.args, "refused", got)
+ }
+ }
+}
+
// claims run verifies release checksum lists with the vendor key the
// binary embeds, never a key from its inputs.
func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) {
@@ -243,7 +366,7 @@ func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) {
}
func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) {
- for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K", "[--out FILE]"} {
+ for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256]\n", "[--out FILE]"} {
if !strings.Contains(activationClaimsUsage, want) {
t.Fatal("usage lacks", want)
}
diff --git a/internal/host/activation_command.go b/internal/host/activation_command.go
index 501726fdb..a6ccae421 100644
--- a/internal/host/activation_command.go
+++ b/internal/host/activation_command.go
@@ -19,7 +19,7 @@ const activationClaimsUsage = `usage: carlos system claims <command> [flags]
fence-credentials --source FILE --bucket B --aws-region R --role ARN
console-config --roster FILE --expect-revision N --bucket B --console-id ID --public-key K [--enable-console-runtime]
console-report --bucket B --aws-region R --expect-revision N --console-id ID --public-key K
- install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K
+ install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256]
install-control
For commands that write, add --dry-run to validate and print the planned operation without writing.
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go
index dc967f819..8f2525c1d 100644
--- a/internal/host/activation_command_linux.go
+++ b/internal/host/activation_command_linux.go
@@ -45,9 +45,14 @@ func runSystemClaims(args []string, out io.Writer) error {
role := fs.String("role", "", "")
outPath := fs.String("out", "", "")
identityPath := fs.String("identity", "", "")
+ upgrade := fs.Bool("upgrade", false, "")
+ from := fs.String("from", "", "")
if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 {
return errActivation
}
+ if upgradeFlagsRefused(args[0], fs, *upgrade, *from) {
+ return errActivation
+ }
ctx := context.Background()
encode := func(value any) error { e := json.NewEncoder(out); e.SetIndent("", " "); return e.Encode(value) }
switch args[0] {
@@ -279,11 +284,15 @@ func runSystemClaims(args []string, out io.Writer) error {
if err != nil {
return err
}
- tag, sha, got, err := runInstallConsole(ctx, store, pub, *identityPath, *source, consoleInstallDir, version.Build, runtime.GOOS+"-"+runtime.GOARCH, *dry)
+ tag, sha, got, err := runInstallConsole(ctx, store, pub, *identityPath, *source, consoleInstallDir, version.Build, runtime.GOOS+"-"+runtime.GOARCH, *from, *dry)
if err != nil {
return err
}
- return encode(map[string]any{"dry_run": *dry, "release_tag": tag, "sha256": sha, "source": *source, "path": got.Path, "previous": got.Previous, "already_installed": got.Already, "mode": "0755"})
+ result := map[string]any{"dry_run": *dry, "release_tag": tag, "sha256": sha, "source": *source, "path": got.Path, "previous": got.Previous, "already_installed": got.Already, "mode": "0755"}
+ if *upgrade {
+ result["upgraded"], result["from"], result["previous_sha"] = true, got.From, got.PreviousSHA
+ }
+ return encode(result)
case "install-control":
if err := verifyOfflineRootMigration(ctx); err != nil {
return err
@@ -297,6 +306,15 @@ func runSystemClaims(args []string, out io.Writer) error {
}
}
+// upgradeFlagsRefused reports whether parsed flags misuse --upgrade and
+// --from: they belong to install-console and come together, so anything
+// else is refused as an unknown flag was before them.
+func upgradeFlagsRefused(command string, fs *flag.FlagSet, upgrade bool, from string) bool {
+ set := false
+ fs.Visit(func(f *flag.Flag) { set = set || f.Name == "upgrade" || f.Name == "from" })
+ return set && (command != "install-console" || !upgrade || from == "")
+}
+
// activationRunner is claims run's runner. Its sums key is the vendor's
// release key that the carlos binary embeds (publicrelease.Default, the same
// key update.go's cliUpdatePubKey holds), so publish-identity accepts only a
diff --git a/internal/host/activation_console_upgrade_rootrunner_linux_test.go b/internal/host/activation_console_upgrade_rootrunner_linux_test.go
new file mode 100644
index 000000000..f1a407f29
--- /dev/null
+++ b/internal/host/activation_console_upgrade_rootrunner_linux_test.go
@@ -0,0 +1,471 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+// consoleUpgradeFixture is a protected console directory installed by
+// install-console with build A, and source files for builds A to D.
+type consoleUpgradeFixture struct {
+ t *testing.T
+ d, dir string
+ src map[string]string // build name to source path
+ body map[string][]byte
+ sha map[string]string
+}
+
+func newConsoleUpgradeFixture(t *testing.T) *consoleUpgradeFixture {
+ t.Helper()
+ d := activationRootFixture(t)
+ f := &consoleUpgradeFixture{t: t, d: d, dir: filepath.Join(d, "opt", "system", "console"), src: map[string]string{}, body: map[string][]byte{}, sha: map[string]string{}}
+ f.check(os.Chmod(d, 0o755))
+ f.check(os.MkdirAll(filepath.Join(d, "src"), 0o755))
+ for _, name := range []string{"A", "B", "C", "D"} {
+ f.body[name] = []byte("console build " + name)
+ f.sha[name] = artifactSHA(f.body[name])
+ f.src[name] = filepath.Join(d, "src", name)
+ f.check(os.WriteFile(f.src[name], f.body[name], 0o755))
+ }
+ if got, err := installProtectedConsole(f.dir, f.src["A"], f.sha["A"], false); err != nil || got.Already {
+ t.Fatal("first install", got, err)
+ }
+ f.want("A", "A")
+ return f
+}
+
+func (f *consoleUpgradeFixture) check(err error) {
+ f.t.Helper()
+ if err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+func (f *consoleUpgradeFixture) path(name string) string { return filepath.Join(f.dir, name) }
+
+// slot requires name to be a root:root 0755 one-link regular file holding
+// build b.
+func (f *consoleUpgradeFixture) slot(name, b string) {
+ f.t.Helper()
+ got, err := os.ReadFile(f.path(name))
+ st, serr := os.Lstat(f.path(name))
+ if err != nil || serr != nil || !bytes.Equal(got, f.body[b]) {
+ f.t.Fatalf("%s: want build %s, got %q %v %v", name, b, got, err, serr)
+ }
+ sys := st.Sys().(*syscall.Stat_t)
+ if st.Mode() != 0o755 || sys.Uid != 0 || sys.Gid != 0 || sys.Nlink != 1 {
+ f.t.Fatalf("%s mode %v uid %d gid %d links %d", name, st.Mode(), sys.Uid, sys.Gid, sys.Nlink)
+ }
+}
+
+// entries lists the directory's names.
+func (f *consoleUpgradeFixture) entries() string {
+ f.t.Helper()
+ list, err := os.ReadDir(f.dir)
+ f.check(err)
+ var names []string
+ for _, e := range list {
+ names = append(names, e.Name())
+ }
+ sort.Strings(names)
+ return strings.Join(names, ",")
+}
+
+// want requires console to hold current, console.prev to hold prev and
+// nothing else in the directory.
+func (f *consoleUpgradeFixture) want(current, prev string) {
+ f.t.Helper()
+ f.slot("console", current)
+ f.slot("console.prev", prev)
+ if got := f.entries(); got != "console,console.prev" {
+ f.t.Fatal("directory holds", got)
+ }
+}
+
+// upgrade runs the upgrade to build to, from the sha of build from.
+func (f *consoleUpgradeFixture) upgrade(to, from string, dry bool) (consoleInstalled, error) {
+ return upgradeProtectedConsole(f.dir, f.src[to], f.sha[to], f.sha[from], dry)
+}
+
+// refused requires the upgrade to build to from build from to refuse, dry
+// and not, and the directory listing to be unchanged by either.
+func (f *consoleUpgradeFixture) refused(what, to, from string) {
+ f.t.Helper()
+ before := f.entries()
+ for _, dry := range []bool{true, false} {
+ if got, err := f.upgrade(to, from, dry); err == nil {
+ f.t.Fatal("accepted", what, dry, got)
+ }
+ if after := f.entries(); after != before {
+ f.t.Fatal(what, "changed the directory", before, after)
+ }
+ }
+}
+
+// TestRootActivationConsoleUpgrade is install-console --upgrade as real
+// root: the swap, its refusals, crash recovery, a leftover temp file, the
+// Already rerun and the dry run.
+func TestRootActivationConsoleUpgrade(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+
+ // A wrong --from refuses and changes nothing; so does a malformed one.
+ f.refused("wrong from", "B", "C")
+ f.want("A", "A")
+ for _, from := range []string{strings.ToUpper(f.sha["A"]), f.sha["A"][:63], ""} {
+ for _, dry := range []bool{true, false} {
+ if _, err := upgradeProtectedConsole(f.dir, f.src["B"], f.sha["B"], from, dry); err == nil {
+ t.Fatalf("accepted from %q", from)
+ }
+ }
+ }
+ // A source that does not hash to the target refuses.
+ if _, err := upgradeProtectedConsole(f.dir, f.src["C"], f.sha["B"], f.sha["A"], false); err == nil {
+ t.Fatal("accepted another source")
+ }
+ f.want("A", "A")
+
+ // The dry run checks and reports, and changes nothing.
+ got, err := f.upgrade("B", "A", true)
+ if err != nil || got.Already || got.From != f.sha["A"] || got.PreviousSHA != f.sha["A"] || got.Path != f.path("console") || got.Previous != f.path("console.prev") {
+ t.Fatal("dry run", got, err)
+ }
+ f.want("A", "A")
+
+ // Upgrade A to B: prev is A, current is B, no temp file left.
+ got, err = f.upgrade("B", "A", false)
+ if err != nil || got.Already || got.From != f.sha["A"] || got.PreviousSHA != f.sha["A"] {
+ t.Fatal("upgrade", got, err)
+ }
+ f.want("B", "A")
+
+ // A rerun is Already and changes nothing, dry or not.
+ for _, dry := range []bool{true, false} {
+ got, err = f.upgrade("B", "A", dry)
+ if err != nil || !got.Already || got.PreviousSHA != f.sha["A"] {
+ t.Fatal("rerun", dry, got, err)
+ }
+ f.want("B", "A")
+ }
+
+ // A malformed --from refuses even when the console is already current.
+ for _, from := range []string{strings.ToUpper(f.sha["A"]), f.sha["A"][:63], ""} {
+ for _, dry := range []bool{true, false} {
+ if _, err := upgradeProtectedConsole(f.dir, f.src["B"], f.sha["B"], from, dry); err == nil {
+ t.Fatalf("Already with from %q", from)
+ }
+ }
+ }
+
+ // Without --upgrade an occupied slot still refuses, as before.
+ for _, b := range []string{"B", "C"} {
+ for _, dry := range []bool{true, false} {
+ if _, err := installProtectedConsole(f.dir, f.src[b], f.sha[b], dry); err == nil {
+ t.Fatal("first install over an installed console", b, dry)
+ }
+ }
+ }
+ f.want("B", "A")
+
+ // The current console's shape: each refuses, nothing changes.
+ cur := f.path("console")
+ shape := func(what string, setup, undo func()) {
+ t.Helper()
+ setup()
+ f.refused(what, "C", "B")
+ undo()
+ f.want("B", "A")
+ }
+ shape("current symlink", func() { f.check(os.Rename(cur, cur+".real")); f.check(os.Symlink(cur+".real", cur)) }, func() { f.check(os.Remove(cur)); f.check(os.Rename(cur+".real", cur)) })
+ shape("current not root's", func() { f.check(os.Chown(cur, 1000, 1000)) }, func() { f.check(os.Chown(cur, 0, 0)) })
+ shape("current owner not root", func() { f.check(os.Chown(cur, 1000, 0)) }, func() { f.check(os.Chown(cur, 0, 0)) })
+ shape("current group not root", func() { f.check(os.Chown(cur, 0, 1000)) }, func() { f.check(os.Chown(cur, 0, 0)) })
+ shape("current two links", func() { f.check(os.Link(cur, cur+".link")) }, func() { f.check(os.Remove(cur + ".link")) })
+ shape("current group writable", func() { f.check(os.Chmod(cur, 0o775)) }, func() { f.check(os.Chmod(cur, 0o755)) })
+ shape("current not executable by others", func() { f.check(os.Chmod(cur, 0o750)) }, func() { f.check(os.Chmod(cur, 0o755)) })
+ shape("current directory", func() { f.check(os.Rename(cur, cur+".real")); f.check(os.Mkdir(cur, 0o755)) }, func() { f.check(os.Remove(cur)); f.check(os.Rename(cur+".real", cur)) })
+
+ // console.prev's shape: a symlink, a directory or the wrong owner each
+ // refuses.
+ prev := f.path("console.prev")
+ shape("prev symlink", func() { f.check(os.Rename(prev, prev+".real")); f.check(os.Symlink(prev+".real", prev)) }, func() { f.check(os.Remove(prev)); f.check(os.Rename(prev+".real", prev)) })
+ shape("prev directory", func() { f.check(os.Rename(prev, prev+".real")); f.check(os.Mkdir(prev, 0o755)) }, func() { f.check(os.Remove(prev)); f.check(os.Rename(prev+".real", prev)) })
+ shape("prev not root's", func() { f.check(os.Chown(prev, 1000, 1000)) }, func() { f.check(os.Chown(prev, 0, 0)) })
+ shape("prev owner not root", func() { f.check(os.Chown(prev, 1000, 0)) }, func() { f.check(os.Chown(prev, 0, 0)) })
+ shape("prev two links", func() { f.check(os.Link(prev, prev+".link")) }, func() { f.check(os.Remove(prev + ".link")) })
+ shape("prev group writable", func() { f.check(os.Chmod(prev, 0o775)) }, func() { f.check(os.Chmod(prev, 0o755)) })
+
+ // console.prev's bytes are not checked: it is about to be replaced.
+ f.check(os.WriteFile(prev+".x", []byte("an older build"), 0o755))
+ f.check(os.Rename(prev+".x", prev))
+ if got, err = f.upgrade("C", "B", false); err != nil || got.Already || got.PreviousSHA != f.sha["B"] {
+ t.Fatal("upgrade over any prev", got, err)
+ }
+ f.want("C", "B")
+
+ // A run cut off after renaming console to console.prev: console is
+ // absent, console.prev holds the old build and console.next the new.
+ f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["D"], 0o755))
+ f.check(os.Rename(cur, prev))
+ if got := f.entries(); got != "console.next,console.prev" {
+ t.Fatal("crash state", got)
+ }
+ f.refused("crash state, wrong from", "D", "B")
+ f.refused("crash state, from the new build", "D", "D")
+ if got, err = f.upgrade("D", "C", true); err != nil || got.Already || got.PreviousSHA != f.sha["C"] {
+ t.Fatal("crash state dry run", got, err)
+ }
+ if got := f.entries(); got != "console.next,console.prev" {
+ t.Fatal("crash state dry run changed", got)
+ }
+ if got, err = f.upgrade("D", "C", false); err != nil || got.Already || got.From != f.sha["C"] || got.PreviousSHA != f.sha["C"] {
+ t.Fatal("crash recovery", got, err)
+ }
+ f.want("D", "C")
+
+ // With both slots gone there is nothing to upgrade from.
+ f.check(os.Rename(cur, cur+".keep"))
+ f.check(os.Rename(prev, prev+".keep"))
+ f.refused("no console", "A", "D")
+ f.refused("no console, from prev", "A", "C")
+ f.check(os.Rename(cur+".keep", cur))
+ f.check(os.Rename(prev+".keep", prev))
+
+ // A run cut off before its renames leaves a partial console.next: the
+ // dry run leaves it, a rerun removes it and completes.
+ f.check(os.WriteFile(f.path(consoleUpgradeTemp), []byte("partial"), 0o600))
+ if _, err = f.upgrade("A", "D", true); err != nil {
+ t.Fatal("dry run over a leftover", err)
+ }
+ if b, _ := os.ReadFile(f.path(consoleUpgradeTemp)); string(b) != "partial" {
+ t.Fatal("dry run touched the leftover")
+ }
+ if got, err = f.upgrade("A", "D", false); err != nil || got.Already || got.PreviousSHA != f.sha["D"] {
+ t.Fatal("upgrade over a leftover", got, err)
+ }
+ f.want("A", "D")
+
+ // A console.next that is not a regular file refuses and is left.
+ f.check(os.Mkdir(f.path(consoleUpgradeTemp), 0o700))
+ f.refused("console.next directory", "B", "A")
+ f.check(os.Remove(f.path(consoleUpgradeTemp)))
+ f.check(os.Symlink(f.src["B"], f.path(consoleUpgradeTemp)))
+ f.refused("console.next symlink", "B", "A")
+ f.check(os.Remove(f.path(consoleUpgradeTemp)))
+ f.want("A", "D")
+
+ // An upgrade never creates the directory.
+ missing := filepath.Join(f.d, "opt", "system", "other")
+ for _, dry := range []bool{true, false} {
+ if _, err := upgradeProtectedConsole(missing, f.src["B"], f.sha["B"], f.sha["A"], dry); err == nil {
+ t.Fatal("upgraded a missing directory", dry)
+ }
+ }
+ if _, err := os.Lstat(missing); !os.IsNotExist(err) {
+ t.Fatal("upgrade created the directory", err)
+ }
+
+ // A group-writable directory refuses, as for the first install.
+ f.check(os.Chmod(f.dir, 0o775))
+ f.refused("group-writable directory", "B", "A")
+ f.check(os.Chmod(f.dir, 0o755))
+ f.want("A", "D")
+}
+
+// TestRootActivationConsoleUpgradeIdentity runs the upgrade through
+// runInstallConsole: the staged identity must be the published one and
+// name the running build, or nothing changes.
+func TestRootActivationConsoleUpgradeIdentity(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ w := newArtifactWorld(t)
+ identity := w.run(t, w.identityPlan(t)).Output
+ idPath := filepath.Join(f.d, "identity.json")
+ f.check(os.WriteFile(idPath, identity, 0o644))
+ f.body["I"] = []byte("console build")
+ f.sha["I"] = artifactSHA(f.body["I"])
+ f.src["I"] = filepath.Join(f.d, "src", "I")
+ f.check(os.WriteFile(f.src["I"], f.body["I"], 0o755))
+
+ for _, build := range []string{"v0.28.1", "v0.28.3", ""} {
+ for _, dry := range []bool{true, false} {
+ if _, _, got, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, build, "linux-arm64", f.sha["A"], dry); err == nil {
+ t.Fatal("accepted build", build, got)
+ }
+ }
+ }
+ for _, dry := range []bool{true, false} {
+ if _, _, _, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-amd64", f.sha["A"], dry); err == nil {
+ t.Fatal("accepted another platform")
+ }
+ }
+ f.want("A", "A")
+
+ tag, sha, got, err := runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], true)
+ if err != nil || tag != "v0.28.2" || sha != f.sha["I"] || got.Already || got.PreviousSHA != f.sha["A"] {
+ t.Fatal("dry run", tag, sha, got, err)
+ }
+ f.want("A", "A")
+ if _, _, got, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], false); err != nil || got.Already {
+ t.Fatal("upgrade", got, err)
+ }
+ f.want("I", "A")
+ if _, _, got, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", f.sha["A"], false); err != nil || !got.Already {
+ t.Fatal("rerun", got, err)
+ }
+ // Without --upgrade the installed console is still refused.
+ if _, _, _, err = runInstallConsole(t.Context(), w.store, w.pub, idPath, f.src["I"], f.dir, "v0.28.2", "linux-arm64", "", false); err == nil {
+ t.Fatal("first install over an upgraded console")
+ }
+ f.want("I", "A")
+}
+
+// TestRootActivationConsoleUpgradeSourceAlias: an upgrade never takes its
+// source from its own slots or temp file, whether named by a path inside
+// the directory or reached through a bind mount of it (same device and
+// inode, another path). Each refuses with nothing changed.
+func TestRootActivationConsoleUpgradeSourceAlias(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ // console = A, console.prev = B: the target B is in console.prev.
+ prev := f.path("console.prev")
+ f.check(os.WriteFile(prev+".x", f.body["B"], 0o755))
+ f.check(os.Rename(prev+".x", prev))
+ alias := filepath.Join(f.d, "alias")
+ f.check(os.Mkdir(alias, 0o755))
+ f.check(unix.Mount(f.dir, alias, "", unix.MS_BIND, ""))
+ t.Cleanup(func() { unix.Unmount(alias, unix.MNT_DETACH) })
+
+ refuse := func(what, source, to, from string) {
+ t.Helper()
+ before := f.entries()
+ for _, dry := range []bool{true, false} {
+ if got, err := upgradeProtectedConsole(f.dir, source, f.sha[to], f.sha[from], dry); err == nil {
+ t.Fatal("accepted", what, dry, got)
+ }
+ if after := f.entries(); after != before {
+ t.Fatal(what, "changed the directory", before, after)
+ }
+ }
+ }
+ refuse("console.prev by path", prev, "B", "A")
+ refuse("console.prev through a bind mount", filepath.Join(alias, "console.prev"), "B", "A")
+ refuse("console by path", f.path("console"), "A", "B")
+ refuse("console through a bind mount", filepath.Join(alias, "console"), "A", "B")
+ // Another file inside the directory, not a slot, is refused by path.
+ f.check(os.WriteFile(f.path("console.src"), f.body["C"], 0o755))
+ refuse("a file in the directory", f.path("console.src"), "C", "A")
+ f.check(os.Remove(f.path("console.src")))
+
+ // A leftover console.next holding the target.
+ f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["C"], 0o755))
+ refuse("console.next by path", f.path(consoleUpgradeTemp), "C", "A")
+ refuse("console.next through a bind mount", filepath.Join(alias, consoleUpgradeTemp), "C", "A")
+ if b, _ := os.ReadFile(f.path(consoleUpgradeTemp)); !bytes.Equal(b, f.body["C"]) {
+ t.Fatal("console.next changed")
+ }
+ f.check(os.Remove(f.path(consoleUpgradeTemp)))
+ f.slot("console", "A")
+ f.slot("console.prev", "B")
+
+ // The same target from a source outside the directory upgrades.
+ if got, err := f.upgrade("B", "A", false); err != nil || got.Already {
+ t.Fatal("upgrade from an outside source", got, err)
+ }
+ f.want("B", "A")
+}
+
+// TestRootActivationConsoleUpgradeAlreadySyncs: a run cut off after its
+// second rename, before the directory fsync, leaves console = target; the
+// rerun is Already and fsyncs the directory first, and a failed fsync is
+// the rerun's error. A dry rerun does not sync.
+func TestRootActivationConsoleUpgradeAlreadySyncs(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ var dirSt unix.Stat_t
+ f.check(unix.Stat(f.dir, &dirSt))
+ var calls int
+ var fail error
+ saved := consoleUpgradeSync
+ t.Cleanup(func() { consoleUpgradeSync = saved })
+ consoleUpgradeSync = func(fd int) error {
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil || st.Dev != dirSt.Dev || st.Ino != dirSt.Ino {
+ t.Fatal("synced something other than the directory", err)
+ }
+ calls++
+ if fail != nil {
+ return fail
+ }
+ return saved(fd)
+ }
+
+ // The state just before the final fsync.
+ f.check(os.WriteFile(f.path(consoleUpgradeTemp), f.body["B"], 0o755))
+ f.check(os.Rename(f.path("console"), f.path("console.prev")))
+ f.check(os.Rename(f.path(consoleUpgradeTemp), f.path("console")))
+ f.want("B", "A")
+
+ if got, err := f.upgrade("B", "A", true); err != nil || !got.Already || calls != 0 {
+ t.Fatal("dry rerun", got, err, calls)
+ }
+ if got, err := f.upgrade("B", "A", false); err != nil || !got.Already || calls != 1 {
+ t.Fatal("rerun", got, err, calls)
+ }
+ fail = syscall.EIO
+ if got, err := f.upgrade("B", "A", false); err != syscall.EIO || calls != 2 {
+ t.Fatal("rerun with a failing fsync", got, err, calls)
+ }
+ fail = nil
+ f.want("B", "A")
+
+ // A full upgrade syncs once, after its renames.
+ if got, err := f.upgrade("C", "B", false); err != nil || got.Already || calls != 3 {
+ t.Fatal("upgrade", got, err, calls)
+ }
+ f.want("C", "B")
+}
+
+// TestRootActivationConsoleUpgradeOversizedSlot: a slot larger than
+// consoleMaxBytes is refused, not hashed by its prefix, so --from naming
+// the prefix's digest cannot match it.
+func TestRootActivationConsoleUpgradeOversizedSlot(t *testing.T) {
+ f := newConsoleUpgradeFixture(t)
+ cur := f.path("console")
+ big := cur + ".big"
+ f.check(os.WriteFile(big, nil, 0o755))
+ f.check(os.Truncate(big, consoleMaxBytes+2)) // sparse: zeros
+ f.check(os.Rename(big, cur))
+ prefix := zeroDigest(t, consoleMaxBytes+1) // what a LimitReader(max+1) hashes
+ f.sha["prefix"] = prefix
+ f.refused("oversized console", "B", "prefix")
+ if _, err := os.Lstat(cur); err != nil {
+ t.Fatal(err)
+ }
+ // As console.prev in the crash state it is refused too.
+ f.check(os.Rename(cur, f.path("console.prev")))
+ f.refused("oversized console.prev", "B", "prefix")
+}
+
+// zeroDigest is the sha256 of n zero bytes.
+func zeroDigest(t *testing.T, n int64) string {
+ t.Helper()
+ h := sha256.New()
+ buf := make([]byte, 1<<20)
+ for n > 0 {
+ k := int64(len(buf))
+ if n < k {
+ k = n
+ }
+ h.Write(buf[:k])
+ n -= k
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 8d36f6686..51b1f53fa 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -981,6 +981,20 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-armed-box-updates.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ // Census overlay install-console-upgrade (claims install-console
+ // --upgrade --from: a guard-armed box's protected console can be
+ // replaced, not only installed once), loaded last.
+ // census-ruling-install-console-upgrade: RULED by platform-d8, accepted as proposed at da0a33b7 and b913bb65
+ // (docs/superpowers/notes/census-proposal-install-console-upgrade.md).
+ // runSystemClaims is re-pinned at its class, consoleSlotHolds's read
+ // moves into consoleSlotSHA, and upgradeProtectedConsole and
+ // writeConsoleTemp are new, all authority G,J4. Round 2 (Astra's
+ // review): readConsoleSource's read moves into readConsoleSourceFile,
+ // and consoleDigest and consoleCounter.Read are new, also G,J4. No
+ // legacy row; the seal stands.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
// Census overlay moves-offline-fixes (harness Run 9's defects 6 to 8 in
// the offline operator move), loaded last, under
// census-ruling-moves-offline-fixes (platform-1c, 2026-10-20); the
diff --git a/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv
new file mode 100644
index 000000000..5287803a0
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-install-console-upgrade.tsv
@@ -0,0 +1,13 @@
+# Census overlay install-console-upgrade (claims install-console --upgrade --from <sha256>: replace a guard-armed box's protected console, found by platform-d8's harness rolling dev's console to v0.28.5), loaded LAST, after armed-box-updates. PROPOSED-install-console-upgrade, asked of the census owner; the proposal is docs/superpowers/notes/census-proposal-install-console-upgrade.md.
+# Every row keeps its latest existing class and task (authority G,J4, as census-ruling-claim-step6-artifacts and -fix1 ruled for the installer and runSystemClaims); new units take their caller's. No accepted-legacy row, so the seal stands. No box agent, update, recovery or edge path reaches any changed unit. The 9B8 inventory is unchanged.
+# Round 2 (Astra's review of da0a33b7, MERGE: block): F1 refuses a source aliasing console, console.prev or console.next, by path and by device and inode (readConsoleSource's read moves into readConsoleSourceFile); F2 fsyncs the directory before a non-dry Already; F3 refuses a slot over 512 MiB instead of hashing its prefix (consoleDigest, consoleCounter.Read). Same marker; the delta is asked to be re-ruled.
+@coverage internal/host 558ba713ea71cd14d2856d8d2a0dd6cdeb44f22cd61185ca2681dd82fad38cc9
+@remove internal/host/activation_artifacts_linux.go consoleSlotHolds
+@remove internal/host/activation_artifacts_linux.go readConsoleSource
+internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit operator CLI uid 0;basis=as census-ruling-claim-step6-artifacts-fix1; install-console also takes --upgrade --from <sha256>, which upgradeFlagsRefused (no read site) admits only together and only for install-console, refusing them elsewhere as unknown flags were; without --upgrade the install path and its output are unchanged file:Read@1761 28f797267906832947199d7a1620345f036b6c4cb405b3b42ffab94c209a5e01
+internal/host/activation_artifacts_linux.go consoleSlotSHA authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=consoleSlotHolds's read moved here unchanged and tightened: a slot is absent, or a root:root 0755 regular file with one link, checked on the entry not followed and again on the descriptor (same device and inode) then hashed through consoleDigest, which refuses a slot over 512 MiB rather than hashing its prefix; anything else refuses. consoleSlotHolds (now no read site) compares the hash as before file:Fstat@597,file:Fstatat@91 c70ad73a0cb712b0be9d10fe32436c222983278706bde6530ffa0a2d8f34933d
+internal/host/activation_artifacts_linux.go upgradeProtectedConsole authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=source verified against the published identity's console sha256 before the directory is opened; a source path in the console directory refuses, and under the lock so does a source whose descriptor has the device and inode of console, console.prev or console.next (round 2, F1); directory checked as consoleDir and never created; under its exclusive flock console must hash to --from (or be absent with console.prev hashing to --from, a run cut off between its renames), console.prev may hold anything of the same shape; console already the target is Already, after fsyncing the directory on a non-dry run (round 2, F2); a leftover console.next is removed only if a regular file; writes console.next, renames console to console.prev and console.next to console, fsyncs the directory, reads both back through hostfloor.OpenProtected and consoleDigest; dry run writes nothing file:Fstat@2622,file:Fstatat@1640,file:Fstatat@905 03605a615c7ee020f7deca032d2aeecde3fa43b8017c5dd91134374b701b05f4
+internal/host/activation_artifacts_linux.go writeConsoleTemp authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=exclusive no-follow create of console.next in the locked directory, writes the verified body, chmod 0755, requires root:root and one link on the descriptor, fsyncs file:Fstat@353 227c4d8eb65491d4ba0bbd1d91c90bfd82962b2c15f20e59d04f920f36e5d001
+internal/host/activation_artifacts_linux.go readConsoleSourceFile authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console, with or without --upgrade);basis=readConsoleSource's read moved here unchanged (round 2, F1): the source opened with no symlink followed on its path, regular file with one link, at most 512 MiB, refused unless it hashes to the identity's console sha256; it also returns the descriptor's stat so an upgrade can refuse a source that is one of its own slots. readConsoleSource (now no read site) wraps it for the first install file:Fstat@220,file:OpenFile@109,file:ReadAll@302 353d920fac5dc3fab8d6512e5d4ecc19648258a7e580d66cb0e1321e37a8b6ff
+internal/host/activation_artifacts_linux.go consoleDigest authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade) and the first install's slot checks through consoleSlotHolds;basis=round 2, F3: hashes a held console slot through EOF; refuses a size over 512 MiB by fstat and by the bytes read (consoleCounter), never hashing a prefix file:Fstat@80 2dadc9634701dbc006ed8f32e321562a0968af0e0b8433510327c6d7d010db64
+internal/host/activation_artifacts_linux.go consoleCounter.Read authority G,J4 rev3=census-ruling-install-console-upgrade;identity=explicit box installer uid 0 (claims install-console --upgrade);basis=round 2, F3: passes reads through from consoleDigest's bounded reader of a held slot and counts them; no path, no decision of its own file:Read@25,file:Read@66 08f4c255e542f87139c5addb173b18649c7266735b85165b3e63a69e7462755f
diff --git a/internal/provision/testdata/plan14-root-reads-moves-offline-fixes.tsv b/internal/provision/testdata/plan14-root-reads-moves-offline-fixes.tsv
index 06f9f857f..0ab47efc1 100644
--- a/internal/provision/testdata/plan14-root-reads-moves-offline-fixes.tsv
+++ b/internal/provision/testdata/plan14-root-reads-moves-offline-fixes.tsv
@@ -8,7 +8,7 @@
# Fix round 1, finding 3 (Astra: a skipped config read cleared the pending retry): SyncExecWithCallbacks re-pinned (it names each in-scope host done or not, WithHostOutcome), pendingDirs.appeared re-pinned (the same Lstat, now with a retry backoff); internal/appconfig and internal/host re-pinned. The seal moves.
@coverage internal/appconfig d2258ea641530870dc3b7600cda01896b0d647229a3b70b83addcd30b15210aa
@coverage internal/console 7b015747c5ef8a3ee7473ad63f7f99200ce14c63c1b7a47945c212ad9239756b
-@coverage internal/host 4b5858f9fc824fb29e4de678dde6510bf8d5c42715b7d2c49278d1264a08f884
+@coverage internal/host cdfe9b5c19793ca66ddfca7ea9cacf19166511a8927150e9b4f278a758c01473
@coverage internal/schedule a76efa679b9089eff4f6dae8e6f0adc5d92de628e66e16d4a00c81ba70e086ff
@legacy 378070928b39fce4bbb645a70cccc08421a0a8c025e73a90b3590e884286afb4 internal/appconfig/syncexec.go SyncExecWithCallbacks legacy accepted-legacy accepted-legacy;rev3=census-ruling-moves-offline-fixes;basis=route-and-input (J1,J4, main's disposition); the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), through syncExecSerialized: main's body, with a nested row whose write found no instance directory named and skipped (errInstanceDirAbsent, WhenInstanceDirAbsent) instead of failing the pass, and each in-scope host reported done or not done (WithHostOutcome) once the pass has gone through every route; no new read site file:Stat@28417,registry:App@10156,registry:App@10371,registry:App@15917,registry:App@28302,registry:Environment@10164,registry:Environment@10379,registry:Environment@15925,registry:Environment@28310,registry:Host@1031,registry:Host@1460,registry:Host@15425,registry:Host@15752,registry:Host@15850,registry:Host@16682,registry:Host@17033,registry:Host@17968,registry:Host@18509,registry:Host@21436,registry:Host@21653,registry:Host@22211,registry:Host@22401,registry:Host@22410,registry:Host@22429,registry:Host@25330,registry:Host@25947,registry:Host@25999,registry:Host@26155,registry:Host@28811,registry:Host@831,registry:Host@902,registry:Host@996,whole:Route@10049,whole:Route@10110,whole:Route@10151,whole:Route@10268,whole:Route@10302,whole:Route@10366,whole:Route@1412,whole:Route@1441,whole:Route@15337,whole:Route@15387,whole:Route@15912,whole:Route@24926,whole:Route@25340,whole:Route@26044,whole:Route@28201,whole:Route@28234,whole:Route@28297,whole:Route@28398,whole:Route@29048,whole:Route@29121,whole:Route@693,whole:Route@721,whole:Route@750,whole:Route@776,whole:Route@9984 b523044621e10b4a702d5e1b8c5ecd843b645cf72b327487ed424f96956a3a7e
internal/appconfig/syncexec.go SyncExecWithCallbacks legacy accepted-legacy accepted-legacy;rev3=census-ruling-moves-offline-fixes;basis=route-and-input (J1,J4, main's disposition); the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), through syncExecSerialized: main's body, with a nested row whose write found no instance directory named and skipped (errInstanceDirAbsent, WhenInstanceDirAbsent) instead of failing the pass, and each in-scope host reported done or not done (WithHostOutcome) once the pass has gone through every route; no new read site file:Stat@28417,registry:App@10156,registry:App@10371,registry:App@15917,registry:App@28302,registry:Environment@10164,registry:Environment@10379,registry:Environment@15925,registry:Environment@28310,registry:Host@1031,registry:Host@1460,registry:Host@15425,registry:Host@15752,registry:Host@15850,registry:Host@16682,registry:Host@17033,registry:Host@17968,registry:Host@18509,registry:Host@21436,registry:Host@21653,registry:Host@22211,registry:Host@22401,registry:Host@22410,registry:Host@22429,registry:Host@25330,registry:Host@25947,registry:Host@25999,registry:Host@26155,registry:Host@28811,registry:Host@831,registry:Host@902,registry:Host@996,whole:Route@10049,whole:Route@10110,whole:Route@10151,whole:Route@10268,whole:Route@10302,whole:Route@10366,whole:Route@1412,whole:Route@1441,whole:Route@15337,whole:Route@15387,whole:Route@15912,whole:Route@24926,whole:Route@25340,whole:Route@26044,whole:Route@28201,whole:Route@28234,whole:Route@28297,whole:Route@28398,whole:Route@29048,whole:Route@29121,whole:Route@693,whole:Route@721,whole:Route@750,whole:Route@776,whole:Route@9984 b523044621e10b4a702d5e1b8c5ecd843b645cf72b327487ed424f96956a3a7e