feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)
- 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
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user