carlos / platform Public

hostfloor: a guarded run returns the image's stdout only

On a guard-armed box the updater runs a candidate's version and bootstrap
plan through Disk.Run, which used CombinedOutput: any stderr progress or
alert corrupted what the updater parses (platform-d8's harness at G4: the
bootstrap plan's progress lines broke its JSON). runImage now returns stdout
and keeps stderr for the error, as the unarmed path's Output() already did.
This must be in the INSTALLED binary to help, so it fixes later rolls, not
the roll from v0.28.4. Census re-pinned under the same ruling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 02f9cd532674f48f90949382f0bec15819c1dc5b parent 1d8aad2
3 files changed, +60 −1
  • internal/hostfloor/image_linux.go +17 −1
  • internal/hostfloor/runimage_linux_test.go +39 −0
  • internal/provision/testdata/plan14-root-reads-claim-rootaws-sentinel.tsv +4 −0
diff --git a/internal/hostfloor/image_linux.go b/internal/hostfloor/image_linux.go
index b048ac82e..965b02f14 100644
--- a/internal/hostfloor/image_linux.go
+++ b/internal/hostfloor/image_linux.go
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
+ "strings"
"syscall"
)
@@ -68,9 +69,24 @@ func (d Disk) Run(ctx context.Context, path, platform string, args ...string) (s
if _, err = d.VerifyFile(f, platform); err != nil {
return "", err
}
+ return runImage(ctx, f, args...)
+}
+
+// runImage runs the verified image open as f and returns its stdout only.
+// A caller parses that output (a version line, a JSON plan), so anything
+// the image writes to stderr, such as progress or an alert, must not be
+// mixed into it; stderr is kept for the error when the run fails.
+func runImage(ctx context.Context, f *os.File, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "/proc/self/fd/3", args...)
cmd.ExtraFiles = []*os.File{f}
- out, err := cmd.CombinedOutput()
+ var stderr strings.Builder
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
+ err = fmt.Errorf("%w: %s", err, msg)
+ }
+ }
return string(out), err
}
func ExecutingHash() (string, error) {
diff --git a/internal/hostfloor/runimage_linux_test.go b/internal/hostfloor/runimage_linux_test.go
new file mode 100644
index 000000000..839560401
--- /dev/null
+++ b/internal/hostfloor/runimage_linux_test.go
@@ -0,0 +1,39 @@
+//go:build linux
+
+package hostfloor
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// A guarded run returns the image's stdout only. A candidate that writes
+// progress or an alert to stderr must not corrupt the version or JSON the
+// updater parses from stdout (an armed box refused every router update that
+// way); stderr still explains a failure.
+func TestRunImageReturnsStdoutOnly(t *testing.T) {
+ dir := t.TempDir()
+ script := filepath.Join(dir, "img")
+ if err := os.WriteFile(script, []byte("#!/bin/sh\necho noise >&2\necho '{\"ok\":true}'\n[ \"$1\" = fail ] && { echo why >&2; exit 3; }\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Open(script)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ out, err := runImage(context.Background(), f, "ok")
+ if err != nil || out != "{\"ok\":true}\n" {
+ t.Fatalf("out %q err %v", out, err)
+ }
+ if _, err = f.Seek(0, 0); err != nil {
+ t.Fatal(err)
+ }
+ _, err = runImage(context.Background(), f, "fail")
+ if err == nil || !strings.Contains(err.Error(), "why") {
+ t.Fatalf("failure lost its stderr: %v", err)
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-claim-rootaws-sentinel.tsv b/internal/provision/testdata/plan14-root-reads-claim-rootaws-sentinel.tsv
index 8c8032e9b..2bfae57c4 100644
--- a/internal/provision/testdata/plan14-root-reads-claim-rootaws-sentinel.tsv
+++ b/internal/provision/testdata/plan14-root-reads-claim-rootaws-sentinel.tsv
@@ -1,2 +1,6 @@
# Census overlay claim-rootaws-sentinel (a scrubbed root parent's own disabled shared-file sentinels are not an override in a root child, so the child's scrub stays silent; found by platform-d8's deployment harness at G4: a guard-armed box refused every router update because the candidate's version printed the ALERT into the guarded updater's combined output), loaded LAST, after claim-empty-cohort-fix2. RULED (platform-1e, 2026-10-20): accepted. Envelope only: ScrubEnvironment has no classified root-read unit; no row changes and the seal stands.
@coverage internal/rootaws ad641674b84d17a9f6a7b1acb4cf88b68cc2a882812a28c8f622d4fd9616b470
+# Also (harness G4, parent side, same ruling): hostfloor's guarded run returns the image's stdout only through runImage (new), so stderr progress or an alert cannot corrupt the version or plan an updater parses; Disk.Run's exec site moved into runImage at its class, and Disk.Run's row is retired.
+@coverage internal/hostfloor ba748a60d9bd7a55d2d3c4a2fe17fc619c23aa2e813a88b164dff47e04a41a1f
+@remove internal/hostfloor/image_linux.go Disk.Run
+internal/hostfloor/image_linux.go runImage authority F,J4 rev3=census-ruling-claim-rootaws-sentinel;identity=root (the system updater and root's apply through hostfloor.Disk.Run on a guard-armed box);basis=root-input: the exec of the verified, protected image Disk.Run opened and checked, moved unchanged from Disk.Run; stdout is returned and stderr kept only for the error file:CommandContext@89 fbf4a73db9e71f8deefa8d56ece729f489f84689d064e2475f5551a823d6fa59