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
78 lines
3.1 KiB
Go
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
|
|
}
|