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
69 lines
2.8 KiB
Go
69 lines
2.8 KiB
Go
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())
|
|
}
|
|
}
|