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:
2026-07-05 15:32:15 +02:00
parent b7cbded429
commit 8033a522cd
16 changed files with 923 additions and 4 deletions
+122
View File
@@ -0,0 +1,122 @@
package selfupdate
import (
"context"
"log/slog"
"time"
)
// Manager owns the post-restart COMMIT half of the A/B update (TASK D1 Scenario D). After a flip,
// the NEW binary boots with a pending marker on disk; once it has run cleanly for the dwell AND
// core init is done, it calls the wrapper's `commit` to clear the marker. A crash before that →
// systemd + the wrapper roll back to .prev (this Manager never rolls back).
type Manager struct {
stateDir string
runningVersion string
dwell time.Duration
runner WrapperRunner
logger *slog.Logger
now func() time.Time // injectable for tests
sleep func(context.Context, time.Duration)
}
// ManagerConfig wires the commit manager.
type ManagerConfig struct {
StateDir string
RunningVersion string // this binary's own version (main.version)
Dwell time.Duration // 0 → 60s
Runner WrapperRunner
Logger *slog.Logger
}
// NewManager builds the commit manager.
func NewManager(cfg ManagerConfig) *Manager {
dwell := cfg.Dwell
if dwell == 0 {
dwell = 60 * time.Second
}
return &Manager{
stateDir: cfg.StateDir,
runningVersion: cfg.RunningVersion,
dwell: dwell,
runner: cfg.Runner,
logger: orDefaultLogger(cfg.Logger),
now: func() time.Time { return time.Now() },
sleep: func(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
},
}
}
// PendingStatus is the report-facing snapshot: whether an update is awaiting commit, and (when so)
// the target version. Read synchronously at report-build time — cheap (one stat+parse).
type PendingStatus struct {
Pending bool
Version string
}
// Status returns the current pending-marker state for the host report. A read error is treated as
// "not pending" for the report (the loud path is the commit goroutine's WARNs), never fatal.
func (m *Manager) Status() PendingStatus {
pm, err := readPending(m.stateDir)
if err != nil || pm == nil {
return PendingStatus{}
}
return PendingStatus{Pending: true, Version: pm.NewVersion}
}
// SelfUpdatePending satisfies hub.SelfUpdateReporter (the report seam): pending + awaited version.
func (m *Manager) SelfUpdatePending() (bool, string) {
s := m.Status()
return s.Pending, s.Version
}
// MaybeCommit runs the startup commit decision, blocking for the dwell when a matching update is
// pending. Call it in a goroutine AFTER core init (config parsed, local API up, control loop
// started) — never on the startup critical path. Behaviour (Scenario C5 + D):
// - no pending marker → nothing to do (the common case).
// - pending.new_version == this running version → dwell, then `commit` (clears the marker).
// - pending.new_version != running version → do NOT commit; loud WARN; leave the marker so the
// hub report shows pending=true (a human decides — the box is in a weird state).
func (m *Manager) MaybeCommit(ctx context.Context) {
pm, err := readPending(m.stateDir)
if err != nil {
m.logger.Error("selfupdate: cannot read pending marker — not committing", "err", err)
return
}
if pm == nil {
return // no update in flight
}
if pm.NewVersion != m.runningVersion {
// The running binary is NOT the one this pending marker describes. Do not commit (committing
// would bless a state we can't explain). Leave the marker → the report surfaces pending=true.
m.logger.Warn("selfupdate: pending marker version does not match the running binary — NOT committing (human review)",
"pending_new_version", pm.NewVersion, "running_version", m.runningVersion, "pending_old_version", pm.OldVersion)
return
}
m.logger.Warn("selfupdate: new version running — dwelling before commit",
"version", m.runningVersion, "prev", pm.OldVersion, "dwell", m.dwell)
m.sleep(ctx, m.dwell)
if ctx.Err() != nil {
// Shutting down before the dwell elapsed — leave pending; the next start re-dwells and
// commits. A restart is not a crash (no OnFailure), so this does not trigger rollback.
m.logger.Warn("selfupdate: shutdown before commit dwell elapsed — pending left for next start")
return
}
if m.runner == nil {
m.logger.Error("selfupdate: no wrapper runner — cannot commit (marker left)")
return
}
stdout, stderr, err := m.runner.Run(ctx, wrapperPath, "commit")
if err != nil {
m.logger.Error("selfupdate: commit failed (marker left; will retry next start)", "err", err, "stderr", string(stderr))
return
}
m.logger.Warn("selfupdate: update committed", "version", m.runningVersion, "wrapper", trim(stdout))
}