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
@@ -0,0 +1,68 @@
package signedjobs
import (
"context"
"testing"
"time"
)
// TASK D1 Group B — the agent_update op class RIDES the same LOCKED gate pipeline as every other
// destructive op. These tests use the REAL authz.Verifier + reconcile.Gate (via newRealGateRunner)
// over genuinely-minted signed blobs, asserting agent_update is gated identically to storage_wipe.
const agentUpdateParamsJSON = `{"version":"0.70.1","sha256":"` +
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + `"}`
// A correctly-signed agent_update by the PINNED operational key reaches the executor (a fake here;
// the real executor is unit-tested in internal/selfupdate). Proves the class is authorized, not
// silently dropped.
func TestRunner_ValidSignedAgentUpdateExecutes(t *testing.T) {
s := newTestSigner(t)
r, src, exec := newRealGateRunner(t, s)
now := time.Now().UTC()
src.add(mintJobOp(t, s, "agent_update", "au1", testHost, "", "ops-1", agentUpdateParamsJSON, now, now.Add(time.Hour)))
if _, err := r.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if exec.count() != 1 || exec.calls[0] != "agent_update" {
t.Fatalf("executor calls = %v, want one agent_update", exec.calls)
}
if !src.wasCompleted("au1") {
t.Error("valid agent_update job not cleared")
}
}
// A NON-PINNED signer's agent_update is REJECTED — the executor is never called. This is the
// companion to the class-allowlist question: agent_update is classified Destructive
// (reconcile.Classify), so an unsigned/wrong-key op cannot reach the binary swap. If the class were
// ever mis-classified Benign, this signature check would be bypassed and the test would fail.
func TestRunner_NonPinnedAgentUpdateRejected(t *testing.T) {
pinned := newTestSigner(t)
attacker := newTestSigner(t) // not pinned
r, src, exec := newRealGateRunner(t, pinned)
now := time.Now().UTC()
src.add(mintJobOp(t, attacker, "agent_update", "au1", testHost, "", "ops-1", agentUpdateParamsJSON, now, now.Add(time.Hour)))
r.RunOnce(context.Background())
if exec.count() != 0 {
t.Errorf("a non-pinned agent_update was EXECUTED (count=%d) — the binary swap must be gated", exec.count())
}
if !src.wasCompleted("au1") {
t.Error("rejected agent_update job should be cleared")
}
}
// An agent_update targeting ANOTHER host is rejected on this host (anti-retarget) — an operator
// can't accidentally push a build to the wrong box.
func TestRunner_AgentUpdateRetargetRejected(t *testing.T) {
s := newTestSigner(t)
r, src, exec := newRealGateRunner(t, s)
now := time.Now().UTC()
src.add(mintJobOp(t, s, "agent_update", "au1", "some-other-host", "", "ops-1", agentUpdateParamsJSON, now, now.Add(time.Hour)))
r.RunOnce(context.Background())
if exec.count() != 0 {
t.Errorf("an agent_update for another host was executed here (count=%d)", exec.count())
}
}