8033a522cd
- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
blesses replacing the root-adjacent binary). classify test + companion
(TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
+ WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
commit + loud WARN + marker left for report visibility; shutdown-before-dwell
leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
the report seam + MaybeCommit goroutine after core init.
Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
118 lines
3.8 KiB
Go
118 lines
3.8 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// writePending drops a pending.json fixture into the staging dir.
|
|
func writePending(t *testing.T, stateDir string, m PendingMarker) {
|
|
t.Helper()
|
|
dir := stagingDir(stateDir)
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := json.Marshal(m)
|
|
if err := os.WriteFile(filepath.Join(dir, "pending.json"), b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func newMgr(t *testing.T, stateDir, runningVersion string, wrap WrapperRunner, buf *bytes.Buffer) *Manager {
|
|
t.Helper()
|
|
var w io.Writer = io.Discard
|
|
if buf != nil {
|
|
w = buf
|
|
}
|
|
m := NewManager(ManagerConfig{
|
|
StateDir: stateDir,
|
|
RunningVersion: runningVersion,
|
|
Dwell: time.Hour, // never elapses in the test; we drive it via the sleep seam
|
|
Runner: wrap,
|
|
Logger: slog.New(slog.NewTextHandler(w, nil)),
|
|
})
|
|
// Replace the real dwell sleep with an instant one so the test doesn't block.
|
|
m.sleep = func(context.Context, time.Duration) {}
|
|
return m
|
|
}
|
|
|
|
// Scenario D: pending marker names THIS version → after the dwell, `commit` is called.
|
|
func TestManager_CommitsMatchingVersion(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
writePending(t, stateDir, PendingMarker{OldVersion: "0.70.0", NewVersion: "0.70.1", SHA256: "ab", AppliedAt: "t"})
|
|
wrap := &fakeWrapper{}
|
|
m := newMgr(t, stateDir, "0.70.1", wrap, nil)
|
|
|
|
m.MaybeCommit(context.Background())
|
|
|
|
if len(wrap.calls) != 1 || wrap.calls[0][1] != "commit" {
|
|
t.Fatalf("expected exactly one `commit` call, got %v", wrap.calls)
|
|
}
|
|
}
|
|
|
|
// Scenario C5: pending marker version != running version → commit is NEVER called; a loud WARN is
|
|
// logged; the marker is LEFT (so the report keeps showing pending=true).
|
|
func TestManager_VersionMismatchDoesNotCommit(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
writePending(t, stateDir, PendingMarker{OldVersion: "0.70.0", NewVersion: "0.70.5", SHA256: "ab", AppliedAt: "t"})
|
|
wrap := &fakeWrapper{}
|
|
buf := &bytes.Buffer{}
|
|
m := newMgr(t, stateDir, "0.70.1", wrap, buf) // running 0.70.1, marker says 0.70.5
|
|
|
|
m.MaybeCommit(context.Background())
|
|
|
|
if len(wrap.calls) != 0 {
|
|
t.Errorf("commit called despite version mismatch: %v", wrap.calls)
|
|
}
|
|
if !bytes.Contains(buf.Bytes(), []byte("NOT committing")) {
|
|
t.Errorf("expected a loud WARN; logs:\n%s", buf.String())
|
|
}
|
|
// Marker must remain so the report still flags pending.
|
|
if p, _ := readPending(stateDir); p == nil {
|
|
t.Error("pending marker was removed on a version mismatch (report would lose visibility)")
|
|
}
|
|
// And the report seam reflects it.
|
|
if pending, ver := m.SelfUpdatePending(); !pending || ver != "0.70.5" {
|
|
t.Errorf("SelfUpdatePending() = %v/%q, want true/0.70.5", pending, ver)
|
|
}
|
|
}
|
|
|
|
// No pending marker → nothing happens (the common steady state).
|
|
func TestManager_NoPendingNoOp(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
wrap := &fakeWrapper{}
|
|
m := newMgr(t, stateDir, "0.70.1", wrap, nil)
|
|
m.MaybeCommit(context.Background())
|
|
if len(wrap.calls) != 0 {
|
|
t.Errorf("commit/anything called with no pending marker: %v", wrap.calls)
|
|
}
|
|
if pending, _ := m.SelfUpdatePending(); pending {
|
|
t.Error("SelfUpdatePending() true with no marker")
|
|
}
|
|
}
|
|
|
|
// Shutdown before the dwell elapses → no commit, marker left for the next start.
|
|
func TestManager_ShutdownBeforeDwellLeavesPending(t *testing.T) {
|
|
stateDir := t.TempDir()
|
|
writePending(t, stateDir, PendingMarker{NewVersion: "0.70.1"})
|
|
wrap := &fakeWrapper{}
|
|
m := newMgr(t, stateDir, "0.70.1", wrap, nil)
|
|
// A cancelled ctx before the (seam) sleep → MaybeCommit sees ctx.Err() and bails.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
m.MaybeCommit(ctx)
|
|
if len(wrap.calls) != 0 {
|
|
t.Errorf("commit called during shutdown: %v", wrap.calls)
|
|
}
|
|
if p, _ := readPending(stateDir); p == nil {
|
|
t.Error("pending marker removed on shutdown-before-commit")
|
|
}
|
|
}
|