Files
admin 8033a522cd 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
2026-07-05 15:32:15 +02:00

78 lines
3.1 KiB
Go

// Package selfupdate implements the AGENT-Go half of the operator-signed A/B self-update (TASK D1).
// The ROOT half (the atomic binary flip + rollback) is the felhom-selfupdate-guarded shell wrapper
// (configs/); systemd owns the crash-loop auto-rollback. This package:
// - downloads the operator-pinned binary from the configured artifact host,
// - verifies it against the SIGNED sha256 (the only integrity root),
// - hands it to the wrapper's `apply` verb via `sudo -n` (the Executor, driven by signedjobs),
// - after the NEW binary has run cleanly for the dwell, calls the wrapper's `commit` (the Manager).
//
// The design principle (SPIKE-agent-selfupdate-2026-07-05): the thing that performs rollback is
// never the thing being updated. This package only ever STARTS an update and COMMITS a good one;
// it never rolls back (that is systemd + the wrapper, so a crash-looping new binary cannot fail to
// revert itself).
package selfupdate
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
)
// opAgentUpdate is the op class this executor serves (mirrors reconcile.ClassAgentUpdate; the
// literal avoids importing reconcile here just for the string).
const opAgentUpdate = "agent_update"
// versionRe bounds the version string that is interpolated into a URL and a filename. Bare semver
// with an optional pre-release suffix (e.g. 0.70.1, 0.70.2-crash) — no slashes, spaces, or dots-only.
var versionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$`)
// sha256Re is the strict 64-lowercase-hex form of the signed sha.
var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
// PendingMarker is the JSON the wrapper writes at <StateDir>/selfupdate/pending.json after a flip.
// The agent reads it on startup to decide whether to commit. Field names match the wrapper.
type PendingMarker struct {
OldVersion string `json:"old_version"`
NewVersion string `json:"new_version"`
SHA256 string `json:"sha256"`
AppliedAt string `json:"applied_at"`
}
// stagingDir is the agent-writable staging subdir (spike S4a: agent-owned StateDir, 0750).
func stagingDir(stateDir string) string { return filepath.Join(stateDir, "selfupdate") }
// pendingPath is the wrapper's pending-marker path.
func pendingPath(stateDir string) string { return filepath.Join(stagingDir(stateDir), "pending.json") }
// readPending returns the pending marker, or (nil, nil) when none exists.
func readPending(stateDir string) (*PendingMarker, error) {
data, err := os.ReadFile(pendingPath(stateDir))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var m PendingMarker
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("selfupdate: pending marker unparseable: %w", err)
}
return &m, nil
}
// interpolateURL substitutes the (validated) version into the URL template's {version} placeholder.
func interpolateURL(template, version string) string {
return regexp.MustCompile(`\{version\}`).ReplaceAllString(template, version)
}
// noopLogger is a discard logger for nil-safety.
func orDefaultLogger(l *slog.Logger) *slog.Logger {
if l == nil {
return slog.Default()
}
return l
}