claim-untagged-pointer fix round 1: a missing identity object gets its own refusal; comments say what is true; the CLI's both-or-neither check is tested
platform-00's requests, as ruled by the controller: 1. When the bucket holds no identity object under the --identity file's tag, install-channel refuses with "no release identity is published for <tag>, so nothing was installed". "the --identity file is not the identity published for <tag>" stays for an object that exists but differs. Both cases are tested, and a mutation that merges them fails. 2. openChannelPolicy's comment names both files it reads. 3. The witness's and promoteAsSystemUpdate's comments now say what they do: an in-process store.Put (scaffolding) and release.PromoteSystem, tied to the console door only through TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor. 4. TestRunSystemInstallChannelTakesPolicyAndIdentityTogether runs the command in a child of the test binary: --policy alone and --identity alone exit 2 with the pair line, -h lists both flags, and with neither flag the root check is still the first refusal. Census overlay claim-untagged-pointer-fix1 (PROPOSED): envelope only; no unit digest moved. RHB-C19 and the cli.md paragraph are not applied.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+124
−24
docs/superpowers/notes/census-proposal-claim-untagged-pointer.md+11 −0internal/host/activation_console_rootrunner_linux_test.go+15 −13internal/host/installchannel.go+6 −2internal/host/installchannel_identity_test.go+76 −5internal/host/installchannel_linux.go+5 −4internal/provision/plan14_root_reads_test.go+8 −0internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv+3 −0
diff --git a/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md b/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md| index 84b619ee1..8ddb6d184 100644 |
| --- a/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md |
| +++ b/docs/superpowers/notes/census-proposal-claim-untagged-pointer.md |
| @@ -40,3 +40,14 @@ No 2b row is re-pinned. The two re-pinned claim rows keep their class exactly: ` |
| ## Test files |
| These are tests and are outside the census: the new `installchannel_identity_test.go`, the changes to `installchannel_policy_test.go`, `installchannel_policy_linux_test.go` and `installchannel_policy_rootrunner_linux_test.go`, the rewritten root-runner witness `activation_console_rootrunner_linux_test.go`, and the console's `apisystemupdate_test.go`. |
| + |
| +## Fix round 1 (PROPOSED) |
| + |
| +Status: PROPOSED. Overlay `internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv`, loaded after the ruled overlay. Envelope only. |
| + |
| +What changed, all on the `--policy` path or in comments: |
| + |
| +- `channelInstall.channelIdentity`: when the bucket holds no identity object under the `--identity` file's tag, the refusal is now "no release identity is published for <tag>, so nothing was installed". "the --identity file is not the identity published for <tag>, ..." stays for an object that exists but differs. |
| +- `openChannelPolicy`'s doc comment now names both files it reads. Its digest, which covers the declaration and not its doc comment, is unchanged. |
| + |
| +No root-read unit's digest or sites change: `channelInstall.run` and `openChannelPolicy` stand at census-ruling-claim-untagged-pointer's rows. The `internal/host` envelope is re-pinned to `846d5194…d741a5`. The seal stands, and no 9B8 entry is proposed: nothing new writes to a bucket. |
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go| index 7d3534114..39a352476 100644 |
| --- a/internal/host/activation_console_rootrunner_linux_test.go |
| +++ b/internal/host/activation_console_rootrunner_linux_test.go |
| @@ -102,14 +102,13 @@ type activationPhaseResult struct { |
| // |
| // - the box is installed at v0.9.0 (README steps 1 to 4: the binary, |
| // host.env, `carlos bootstrap`, the edge); |
| -// - the operator's system promote of v1.0.0 is `carlos system update |
| -// --version v1.0.0 --spread 0`'s code path before a floor is published: |
| -// the artifact mirrored create-only and release.PromoteSystem with the |
| -// release the console's door resolves, {Version, Source "release:<tag>", |
| -// Artifacts}, so the pointer carries no release tag and no moves |
| -// protocol, exactly as dev's v0.28.3 pointers do (the console's |
| -// TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor pins that |
| -// shape). The real agent and `carlos system apply` then update the box |
| +// - the operator's system promote of v1.0.0 is an in-process store.Put of |
| +// the artifact (scaffolding) and release.PromoteSystem with the untagged |
| +// release {Version, Source "release:<tag>", Artifacts}, so the pointer |
| +// carries no release tag and no moves protocol, as dev's v0.28.3 |
| +// pointers do. It is tied to `carlos system update`'s door only through |
| +// the console's TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, |
| +// which drives the real door with no floor and pins that shape. The real agent and `carlos system apply` then update the box |
| // to v1.0.0, leaving v0.9.0 in carlos.prev; |
| // - its AWS identity record is published by `carlos system box |
| // publish-record` as the agent user (step 5.0); |
| @@ -303,11 +302,14 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| write("/run/systemd/system/"+u+".d/10-rootrunner-network.conf", []byte("[Service]\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n"), 0644) |
| } |
| ctl("daemon-reload") |
| - // The operator promotes v1.0.0 on the box's channel with `carlos system |
| - // update --version v1.0.0 --spread 0`, before any floor is published: the |
| - // console's door mirrors the artifact create-only (mirrorSystemArtifact) |
| - // and, in legacy mode (reserveHostUpdate returns before tagging), |
| - // promotes the release it resolved with nothing added. |
| + // The operator's promote of v1.0.0, before any floor is published. This |
| + // is not the console door or the carlos system update CLI: it is an |
| + // in-process store.Put of the artifact (scaffolding, standing in for the |
| + // door's mirror) and release.PromoteSystem with the untagged release |
| + // {Version, Source "release:<tag>", Artifacts}. That shape is tied to the |
| + // real door only through the console's |
| + // TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, which drives the |
| + // door with no floor and pins exactly this pointer. |
| artifact := release.SystemArtifact{Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)} |
| _, err := store.Put(ctx, artifact.Key, activationCarlosImage, "") |
| check(err) |
diff --git a/internal/host/installchannel.go b/internal/host/installchannel.go| index 2380cce31..63d9aa10c 100644 |
| --- a/internal/host/installchannel.go |
| +++ b/internal/host/installchannel.go |
| @@ -487,7 +487,8 @@ func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointe |
| // channelIdentity is --identity's check (untagged-pointer ruling R1), once |
| // the release key is known: the file verifies as a release identity under |
| // it, is byte for byte the object published at release.IdentityKey of its |
| -// own tag, and names what the verified pointer names: the same version and |
| +// own tag (none published under it is its own refusal), and names what the |
| +// verified pointer names: the same version and |
| // the same sha256 for this platform's artifact, and, when the pointer |
| // carries a release tag, that tag. It returns the identity's bytes and tag. |
| // Nothing is read through the pointer's tag. |
| @@ -505,7 +506,10 @@ func (in channelInstall) channelIdentity(ctx context.Context, s release.Store, p |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| return nil, "", fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", tag, err) |
| } |
| - if err != nil || !bytes.Equal(published, in.identity) { |
| + if err != nil { |
| + return nil, "", fmt.Errorf("no release identity is published for %s, so nothing was installed", tag) |
| + } |
| + if !bytes.Equal(published, in.identity) { |
| return nil, "", fmt.Errorf("the --identity file is not the identity published for %s, so nothing was installed", tag) |
| } |
| router, ok := id.Current.Artifacts[in.a.platform] |
diff --git a/internal/host/installchannel_identity_test.go b/internal/host/installchannel_identity_test.go| index c0c5e033e..0387f0d78 100644 |
| --- a/internal/host/installchannel_identity_test.go |
| +++ b/internal/host/installchannel_identity_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "os" |
| + "os/exec" |
| "path/filepath" |
| "strings" |
| "testing" |
| @@ -27,10 +28,12 @@ import ( |
| // TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, pins the pointer's |
| // shape these fixtures promote. |
| -// promoteAsSystemUpdate moves stable to version as `carlos system update |
| -// --version <version> --spread 0` does before a floor is published: the |
| -// artifact mirrored create-only, then PromoteSystem with the release the door |
| -// resolved and nothing added. |
| +// promoteAsSystemUpdate moves stable to version with the pointer shape `carlos |
| +// system update` writes before a floor is published. It does not run the |
| +// door: it calls f.store.Put for the artifact (scaffolding) and |
| +// release.PromoteSystem in-process with the untagged release {Version, |
| +// Source "release:<version>", Artifacts}. The shape is tied to the console's |
| +// door only through TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor. |
| func (f *channelFixture) promoteAsSystemUpdate(t *testing.T, version string, body []byte) { |
| t.Helper() |
| ctx := context.Background() |
| @@ -268,7 +271,14 @@ func TestInstallChannelPolicyIdentityRefusals(t *testing.T) { |
| t.Fatal(err) |
| } |
| return identity |
| - }, notPublished}, |
| + }, "no release identity is published for v1.4.0, so nothing was installed"}, |
| + {"signed for a tag never published", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| + wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), identityRelease("v1.4.9", f.body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return wire |
| + }, "no release identity is published for v1.4.9, so nothing was installed"}, |
| {"another release's identity", func(t *testing.T, f *channelFixture, identity []byte) []byte { |
| return f.publishIdentity(t, identityRelease("v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n"))) |
| }, "the --identity file names v1.5.0 (v1.5.0), not the release the stable channel names, so nothing was installed"}, |
| @@ -415,3 +425,64 @@ func (f *channelFixture) identityOf(t *testing.T, tag string) []byte { |
| } |
| return raw |
| } |
| + |
| +// installChannelCLIEnv makes this test binary run RunSystemInstallChannel |
| +// with the arguments it holds (one per line), so its exits can be observed. |
| +const installChannelCLIEnv = "CARLOS_TEST_INSTALL_CHANNEL_ARGS" |
| + |
| +// TestInstallChannelCLIChild is the child runInstallChannelCLI starts; run |
| +// without installChannelCLIEnv it does nothing. |
| +func TestInstallChannelCLIChild(t *testing.T) { |
| + raw, ok := os.LookupEnv(installChannelCLIEnv) |
| + if !ok { |
| + t.Skip("only as runInstallChannelCLI's child") |
| + } |
| + var args []string |
| + if raw != "" { |
| + args = strings.Split(raw, "\n") |
| + } |
| + RunSystemInstallChannel(args) |
| + os.Exit(0) |
| +} |
| + |
| +// runInstallChannelCLI runs `carlos system install-channel args...` in a |
| +// child of this test binary, not as root, and returns its combined output |
| +// and exit code. |
| +func runInstallChannelCLI(t *testing.T, args ...string) (string, int) { |
| + t.Helper() |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestInstallChannelCLIChild$") |
| + cmd.Env = append(os.Environ(), installChannelCLIEnv+"="+strings.Join(args, "\n"), "CARLOS_SYSTEM_CHANNEL=") |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + code := 0 |
| + if ee, ok := err.(*exec.ExitError); ok { |
| + code = ee.ExitCode() |
| + } else if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(out), code |
| +} |
| + |
| +// The command's both-or-neither check (untagged-pointer R1, platform-00): |
| +// --policy alone and --identity alone are refused with exit 2 and the pair |
| +// line, before the root check and before any file is read; -h lists both |
| +// flags. |
| +func TestRunSystemInstallChannelTakesPolicyAndIdentityTogether(t *testing.T) { |
| + missing := filepath.Join(t.TempDir(), "absent.json") |
| + for _, args := range [][]string{{"--policy", missing}, {"--identity", missing}} { |
| + out, code := runInstallChannelCLI(t, args...) |
| + if code != 2 || strings.TrimSpace(out) != "--policy and --identity go together, so nothing was installed" { |
| + t.Fatalf("%v: exit %d, %q", args, code, out) |
| + } |
| + } |
| + out, code := runInstallChannelCLI(t, "-h") |
| + if code != 0 || !strings.HasPrefix(out, installChannelUsage) || !strings.Contains(out, " --identity <string> "+installChannelIdentityFlag+"\n") || !strings.Contains(out, " --policy <string> "+installChannelPolicyFlag+"\n") { |
| + t.Fatalf("-h: exit %d\n%s", code, out) |
| + } |
| + if os.Geteuid() != 0 { |
| + // With neither flag, unchanged: the root check is the first refusal. |
| + if out, code := runInstallChannelCLI(t); code != 1 || strings.TrimSpace(out) != installChannelNotRoot { |
| + t.Fatalf("no flags: exit %d, %q", code, out) |
| + } |
| + } |
| +} |
diff --git a/internal/host/installchannel_linux.go b/internal/host/installchannel_linux.go| index a4d468b6d..4912789df 100644 |
| --- a/internal/host/installchannel_linux.go |
| +++ b/internal/host/installchannel_linux.go |
| @@ -38,10 +38,11 @@ func ownDir(path string, uid, gid int) error { |
| return unix.Fchown(fd, uid, gid) |
| } |
| -// openChannelPolicy reads --policy's file: opened with O_NOFOLLOW (a |
| -// symlink is refused, never followed) and O_NONBLOCK (a FIFO never blocks |
| -// the open), fstat'd to be a regular file of at most limit bytes, and only |
| -// then read, from the descriptor, up to limit. |
| +// openChannelPolicy reads each operator-staged file install-channel --policy |
| +// takes, the --policy file and the --identity file (readChannelInputs): |
| +// opened with O_NOFOLLOW (a symlink is refused, never followed) and |
| +// O_NONBLOCK (a FIFO never blocks the open), fstat'd to be a regular file of |
| +// at most limit bytes, and only then read, from the descriptor, up to limit. |
| func openChannelPolicy(path string, limit int64) ([]byte, error) { |
| fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| if err != nil { |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 42bca2440..9288448c9 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -896,6 +896,14 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-untagged-pointer.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay claim-untagged-pointer-fix1 (review fix round 1: a |
| + // missing identity object gets its own refusal; openChannelPolicy's |
| + // comment names both files), loaded last. Envelope only. PROPOSED |
| + // (docs/superpowers/notes/census-proposal-claim-untagged-pointer.md, |
| + // "Fix round 1"). The seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv| new file mode 100644 |
| index 000000000..deba92138 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-untagged-pointer-fix1.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay claim-untagged-pointer-fix1 (review fix round 1: platform-00's requests, as ruled by the controller), loaded LAST, after claim-untagged-pointer. PROPOSED: docs/superpowers/notes/census-proposal-claim-untagged-pointer.md, section "Fix round 1". |
| +# Envelope only. channelIdentity gives a missing identity object its own refusal ("no release identity is published for <tag>, so nothing was installed"); openChannelPolicy's doc comment names both files it reads. No root-read unit's digest or sites change: channelInstall.run and openChannelPolicy stand at census-ruling-claim-untagged-pointer's rows, refreshPrev at its ruled row. The seal stands; the 9B8 inventory is unchanged. |
| +@coverage internal/host 846d51942aff38963e0a87ce178024297208125a5eca39816d2c6d1d50d741a5 |