6ab1e7c56c
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
223 lines
8.0 KiB
Go
223 lines
8.0 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
|
)
|
|
|
|
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error. For the os-apply
|
|
// call it snapshots the plan file at call time (the executor removes it afterwards) and answers with report.
|
|
type fakeWrapper struct {
|
|
mu sync.Mutex
|
|
calls [][]string
|
|
err error
|
|
plans []map[string]any
|
|
report string // the OSAPPLY-REPORT JSON; "" = a successful agent_update
|
|
}
|
|
|
|
func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, append([]string{name}, args...))
|
|
if name == osApplyPath && len(args) == 2 && args[0] == "--plan" {
|
|
var p map[string]any
|
|
b, _ := os.ReadFile(args[1])
|
|
_ = json.Unmarshal(b, &p)
|
|
f.plans = append(f.plans, p)
|
|
rep := f.report
|
|
if rep == "" {
|
|
rep = `{"agent_update": {"version": "x", "wrapper_rc": 0}, "mode": "agent_update", "refused": null}`
|
|
}
|
|
return []byte("OSAPPLY-REPORT " + rep + "\n"), nil, f.err
|
|
}
|
|
return []byte("ok"), nil, f.err
|
|
}
|
|
|
|
// applyCalls are the hand-offs to the root wrapper (felhom-os-apply --plan …). Since v0.146.0 the agent never calls
|
|
// `felhom-selfupdate-guarded apply` itself.
|
|
func (f *fakeWrapper) applyCalls() [][]string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out [][]string
|
|
for _, c := range f.calls {
|
|
if c[0] == osApplyPath || (len(c) >= 2 && c[1] == "apply") {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func signedCtx() context.Context {
|
|
return signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"agent_update"}`), Sig: []byte("SIG")})
|
|
}
|
|
|
|
func sha256Of(b []byte) string {
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// artifactServer serves `body` at /…/{version}/felhom-agent.
|
|
func artifactServer(t *testing.T, body []byte) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write(body)
|
|
}))
|
|
}
|
|
|
|
func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor, string) {
|
|
t.Helper()
|
|
stateDir := t.TempDir()
|
|
e := NewExecutor(Config{
|
|
URLTemplate: srv.URL + "/{version}/felhom-agent",
|
|
StateDir: stateDir,
|
|
Runner: wrap,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
e.planDir = t.TempDir()
|
|
return e, stateDir
|
|
}
|
|
|
|
func updateParamsJSON(t *testing.T, version, sha string) json.RawMessage {
|
|
t.Helper()
|
|
b, _ := json.Marshal(map[string]string{"version": version, "sha256": sha})
|
|
return b
|
|
}
|
|
|
|
// Scenario A (executor half): a good download whose sha matches the signed value is staged and
|
|
// handed to `apply` with the EXACT staged path + sha.
|
|
func TestExecutor_HappyPath(t *testing.T) {
|
|
body := []byte("#!/bin/sh\necho v0.70.1\n")
|
|
srv := artifactServer(t, body)
|
|
defer srv.Close()
|
|
wrap := &fakeWrapper{}
|
|
e, stateDir := newExec(t, srv, wrap)
|
|
|
|
sha := sha256Of(body)
|
|
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")
|
|
got, err := os.ReadFile(staged)
|
|
if err != nil || sha256Of(got) != sha {
|
|
t.Fatalf("staged binary missing/mismatch: %v", err)
|
|
}
|
|
calls := wrap.applyCalls()
|
|
if len(calls) != 1 || calls[0][0] != osApplyPath || calls[0][1] != "--plan" {
|
|
t.Fatalf("want exactly one hand-off to felhom-os-apply --plan, got %v", wrap.calls)
|
|
}
|
|
// R-861: the plan carries the SIGNED envelope and the staged path; the wrapper, not the agent, decides.
|
|
p := wrap.plans[0]
|
|
sg, _ := p["signed"].(map[string]any)
|
|
if p["mode"] != "agent_update" || p["layer"] != "host" || p["staged"] != staged || sg["sig"] != "SIG" || sg["blob_b64"] == "" {
|
|
t.Errorf("plan = %v, want mode agent_update + the staged path + the signed envelope", p)
|
|
}
|
|
if _, err := os.Stat(calls[0][2]); !os.IsNotExist(err) {
|
|
t.Error("the plan file was left behind")
|
|
}
|
|
}
|
|
|
|
// R-861: without the signed envelope nothing reaches the root wrapper.
|
|
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): call `felhom-selfupdate-guarded apply` directly again
|
|
// → the happy path's "exactly one hand-off to felhom-os-apply" fails.
|
|
func TestExecutor_NoEnvelopeNoHandOff(t *testing.T) {
|
|
body := []byte("good bytes")
|
|
srv := artifactServer(t, body)
|
|
defer srv.Close()
|
|
wrap := &fakeWrapper{}
|
|
e, _ := newExec(t, srv, wrap)
|
|
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
|
t.Fatal("an update with no signed envelope was handed on")
|
|
}
|
|
if len(wrap.applyCalls()) != 0 {
|
|
t.Fatalf("the root wrapper was called without an envelope: %v", wrap.calls)
|
|
}
|
|
}
|
|
|
|
// A refusal in the wrapper's report is a failure, even when its exit code reads 0.
|
|
func TestExecutor_WrapperRefusalSurfaces(t *testing.T) {
|
|
body := []byte("good bytes")
|
|
srv := artifactServer(t, body)
|
|
defer srv.Close()
|
|
wrap := &fakeWrapper{report: `{"mode": "agent_update", "refused": {"code": "R3", "reason": "the operator signature does not verify"}}`}
|
|
e, _ := newExec(t, srv, wrap)
|
|
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
|
t.Fatal("a refused signed update read as applied")
|
|
}
|
|
}
|
|
|
|
// Scenario C2 + its companion: a download whose sha != the signed value is REFUSED, nothing is
|
|
// handed to apply, and the staged file is removed. The companion (dropping the Go-side verify) is
|
|
// structural: the sha check IS the code under test — if it were removed, this bad binary would
|
|
// reach the apply call (asserted here: applyCalls == 0).
|
|
func TestExecutor_ShaMismatchRefused(t *testing.T) {
|
|
body := []byte("the REAL published bytes")
|
|
srv := artifactServer(t, body)
|
|
defer srv.Close()
|
|
wrap := &fakeWrapper{}
|
|
e, stateDir := newExec(t, srv, wrap)
|
|
|
|
wrongSha := sha256Of([]byte("what the operator signed for a DIFFERENT binary"))
|
|
err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", wrongSha))
|
|
if err == nil {
|
|
t.Fatal("expected sha-mismatch refusal, got nil")
|
|
}
|
|
if len(wrap.applyCalls()) != 0 {
|
|
t.Error("a sha-mismatched binary REACHED the apply call — the verify gate leaked")
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")); !os.IsNotExist(statErr) {
|
|
t.Error("staged file was left behind after a sha mismatch")
|
|
}
|
|
}
|
|
|
|
// Bad params / non-semver version / non-hex sha are refused before any download or apply.
|
|
func TestExecutor_BadParamsRefused(t *testing.T) {
|
|
wrap := &fakeWrapper{}
|
|
e, _ := newExec(t, artifactServer(t, []byte("x")), wrap)
|
|
for name, p := range map[string]json.RawMessage{
|
|
"non-semver version": updateParamsJSON(t, "latest", sha256Of([]byte("x"))),
|
|
"non-hex sha": updateParamsJSON(t, "0.70.1", "NOTHEX"),
|
|
"bad json": json.RawMessage(`{`),
|
|
} {
|
|
if err := e.Execute(context.Background(), "agent_update", p); err == nil {
|
|
t.Errorf("%s: expected refusal", name)
|
|
}
|
|
}
|
|
if len(wrap.calls) != 0 {
|
|
t.Errorf("wrapper invoked on bad params: %v", wrap.calls)
|
|
}
|
|
}
|
|
|
|
// A non-owned op class returns ErrNoExecutor (the chain contract) — never touches anything.
|
|
func TestExecutor_NotOurOp(t *testing.T) {
|
|
e, _ := newExec(t, artifactServer(t, []byte("x")), &fakeWrapper{})
|
|
if err := e.Execute(context.Background(), "storage_wipe", json.RawMessage(`{}`)); !errors.Is(err, signedjobs.ErrNoExecutor) {
|
|
t.Errorf("err = %v, want ErrNoExecutor", err)
|
|
}
|
|
}
|
|
|
|
// A wrapper apply failure surfaces (the executor reports it — the runner then logs + clears).
|
|
func TestExecutor_WrapperFailureSurfaces(t *testing.T) {
|
|
body := []byte("good bytes")
|
|
srv := artifactServer(t, body)
|
|
defer srv.Close()
|
|
wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")}
|
|
e, _ := newExec(t, srv, wrap)
|
|
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
|
t.Fatal("wrapper failure must surface")
|
|
}
|
|
}
|