Skip to content

feat(k8s): Kubernetes backend plugin + desktop deploy path - #4289

Merged
tlongwell-block merged 26 commits into
mainfrom
eva/k8s-backend
Aug 2, 2026
Merged

feat(k8s): Kubernetes backend plugin + desktop deploy path#4289
tlongwell-block merged 26 commits into
mainfrom
eva/k8s-backend

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Kubernetes backend plugin (crates/buzz-backend-kubernetes) + desktop deploy path

Implements docs/remote-agents.md (merged @ 28ae6cd) as ONE PR: the provider
binary, the desktop changes that make it work, the harness inactivity reaper,
the Sprig image, and the conformance/live-test suites.

Channel: buzz-remote-agents (29414326-dba7-402d-b384-b1b34d63a2e6), thread c42b70ef.

What's here (by lane)

  • crates/buzz-backend-kubernetes (Dawn): stdin/stdout JSON provider, info +
    deploy; pure classify.rs (one match arm per spec state-machine row);
    reconcile/GC with ownership-marker gate + same-clock orphan check; per-attempt
    immutable Secrets; three-tier env with clear-then-write authoritative tier.
  • Desktop (Mari): KD3 launch block from resolved descriptor, KD5 pre-secret
    negotiation gate (resolve-once → stage-and-digest → info → protocol gate →
    deploy), KD1 Windows extension strip, bundling (externalBin + Justfile +
    release/canary workflows + stub loops), tauri.windows.conf.json platform
    override (Decision B: no Windows artifact).
  • buzz-acp (Max): KD4 BUZZ_ACP_EXIT_AFTER_INACTIVITY reaper (pool-independent;
    reset only at accepted dispatch; in-flight turn/heartbeat defers, never resets);
    BUZZ_ACP_EXIT_AFTER_INACTIVITY + BUZZ_ACP_NO_PRESENCE reserved. KD8 fix.
  • Image + tests (Perci): Dockerfile.sprig (digest-pinned bases, exec buzz-acp
    PID 1, relay-scoped credential config), image contract script, provider
    conformance suites (golden wire fixtures shared with desktop tests), live-local
    runbook (namespace-scoped, shared-cluster safe).
  • Docs (Sami, first commit): citation re-pin c1bca1b28ae6cd (44/49
    were already byte-exact; 3 offsets fixed) + I3 presence-bound correction (below).

Named spec deviations (deliberate, each with rationale)

  1. No baked default image yet. ghcr.io/block/buzz-sprig is unpublished
    (verified: anonymous pull 403 vs control 200). Omitted image returns an
    in-band field-required error instead of a default.
  2. Image override STRICTER than spec §Image: digest-only
    (name@sha256:<64hex>); ALL tags rejected; name:tag@digest normalized.
    With no baked default the override is the only path, so tag-acceptance would
    make mutability the v1 norm. Strictness is reversible; a moved tag under an
    nsec is not. Baked digest default + tag re-acceptance = follow-up with image
    publish.
  3. imagePullSecrets not in schema (v1). Explicit user images may rely on
    namespace-preprovisioned pull credentials — the substrate boundary. Field
    added only if the publish decision proves it necessary. 9-field budget intact.
  4. Decision A closed: writable empty workspace. Nest projection = named
    follow-up; no image-side scaffolding.
  5. Decision D overridden by Tyler (event b55398d8): provider ships bundled
    with the desktop like buzz-acp/buzz-agent; spec §Distribution's separate
    release workflow deleted for v1.
  6. I3/vision presence bound corrected 90s → 180s. PRESENCE_TTL_SECS moved in
    perf(presence): reduce heartbeat frequency #3783 during this spec's base→merge window; the number was inherited, not
    chosen. Spec :206/:216/:928 + inline quote + VISION_REMOTE_AGENTS.md:59
    corrected. ← Tyler: the vision is your document; this edit is flagged for
    your explicit eyes.
  7. Spec citations are pinned to 28ae6cd (main at spec merge) and resolve
    there, not at this PR's head — this PR's own lanes move
    crates/buzz-acp/src/lib.rs by ~100 lines (19 citations across KD4/KD6/KD7/
    §Stop/§Launch data). Known Defects rows fixed BY this PR retire on merge;
    the section documents main as of the pin.
  8. KD7 grace tension declared: pod terminationGracePeriodSeconds=60 vs
    KD7's measured ~87s shutdown tail at parallelism 10 (~197s at cap 32).
    KD7 is ruled out of scope, so L1-3's "enough grace for full graceful
    shutdown" is NOT met at default config — deliberate, resolved by the KD7
    follow-up, not silently.

Question for Tyler

Will ghcr.io/block/buzz-sprig publish PUBLIC? If private-by-policy, §Image needs
an imagePullSecrets story before the baked-default follow-up can land.

Out of scope (named follow-ups)

KD6 exit-code contract + KD7 shutdown budget (gate OnFailure), OnFailure restart
policy, Windows provider binary, PVCs/nest projection, mesh deployability,
sprig image publish workflow + baked multi-arch digest default.

Reproduce locally (four traps that cost us real time)

1. Git hooks inherit the invoking shell's PATH — pin the shell, not just your
verification commands.
rust-toolchain.toml pins 1.95.0, but the rustup shim
that honors that pin lives in ~/.cargo/bin. If Homebrew's cargo is earlier on
PATH, cargo in this repo is 1.89.0, which cannot build the workspace at all:

$ /opt/homebrew/bin/cargo check -p buzz-db
error: rustc 1.89.0 is not supported by the following packages:
  sqlx@0.9.0 requires rustc 1.94.0
  ...                                                    # exit 101

Verifying with PATH="$HOME/.cargo/bin:$PATH" cargo test does not protect the
push: lefthook's pre-pushjust test-unit re-resolves cargo from the
shell's own PATH, so a green local run is followed by a hook failure on a crate
you never touched. Export the PATH for the whole shell, not per-command. This
bit twice.

2. Line-scope your mutations, or the mutation edits its own detector. When
mutation-testing the respond-to guard, a whole-file sed on the mode literal
touches 5 sites — the guard and the fixtures/assertions that test it. The
mutation and its detector move together and the suite stays green, which reads
as "this code is dead" when it actually means "you deleted the experiment":

# WRONG — 5 sites, guard and tests mutate together
$ sed -i '' 's/"allowlist"/"allowlist-DISABLED"/g' src/env.rs
test result: ok. 145 passed; 0 failed          # false survivor

# RIGHT — 1 site, anchored to the guard's own definition line
$ sed -i '' '/^const RESPOND_TO_ALLOWLIST/s/"allowlist"/"allowlist-DISABLED"/' src/env.rs
failures:
    env::tests::allowlist_mode_with_an_empty_list_is_refused
    env::tests::an_allowlist_entry_that_is_not_64_hex_is_refused
test result: FAILED. 143 passed; 2 failed      # real kill

Restore by copying a pristine file back and confirming git diff --stat is
empty, not by re-running an inverse sed.

3. A completeness guard is not a correctness guard. The shared wire fixture
tests/fixtures/provider-wire/deploy-full-launch.request.json passed every test
we had while containing four classes of invented data (wrong respond_to
encoding, an env key no emitter writes, allowlist entries that fail the
harness's own 64-hex rule, a launch.env key from no descriptor layer). The
provider's tests could not have caught this: its types are deliberately
indifferent to these values (Option<String>, Vec<String>, arbitrary map), so
"the provider parses it" was never evidence that the desktop emits it. The fix
was not a stronger provider assertion but a rule about provenance — "recorded"
means executed-and-transcribed, and the desktop's whole-object equality test is
the only enforcement that can exist. See the fixture README.

4. Every drift this arc was a value that agreed with itself. Five invented
values were found, and not one was caught by an assertion failing — each was
caught by someone asking where a value came from. A named constant referenced
symbolically on both the fixture and assertion side. A sed that mutated its
own detector. Six probe rows that all died at the same unrelated error. A
descriptor struct literal compared against a fixture built from that literal
(launch.args: ["run","--session"], which the resolver actually returns as
["acp"]). The general defense is not more assertions but provenance: a stub is
a control that varies nothing, and the more faithful it looks the better it
hides. Ask what executed, not what passed.

Fixture-test determinism caveat (post-verification, Quinn + Dawn). The
desktop's whole-object fixture test calls the real resolver, which consults a
process-global harness registry whose own docs require registry_test_lock
for any test touching it. The fixture test holds no lock and is nonetheless
deterministic — but by containment, not by ordering. Measured, not derived:
planting a definition with id: "goose" directly into the registry (bypassing
the loader) changes the resolved descriptor from args: ["acp"] to
args: ["--poisoned"], so resolve_effective_harness_descriptor does
reach the registry for this id — it does not short-circuit on the builtin
table first. Two controls discriminate: an empty registry and a registry
poisoned under a different id both return ["acp"]. What actually protects
the test is that the registry has exactly one writer
(update_loaded_harness_registry, reached only via
warm_harness_registry_from_dir) — but that writer concatenates two
sources of unequal strength (custom_harnesses.rs:319-326). Custom files
pass through load_custom_harnesses, whose check_id_collision rejects the
reserved builtin id goose case-insensitively at the loader — and that leg
is tested (load_applies_id_collision_check writes a real goose.json and
asserts the loader drops it). Preset definitions
(preset_harness_definitions, presets.rs:177-193) are a bare .map over
PRESET_HARNESSES with no collision check — exhaustive call-site
enumeration at 60007fda4 finds four production check_id_collision sites,
none on the preset path. That leg holds only because goose is not in the
preset table today (intersection of TIER1 and preset ids is empty) —
executed, not just read: adding a preset with id: "goose",
args: ["--poisoned"] and warming via the normal preset-only path
(warm_harness_registry_from_dir(None), no custom dir, no direct writer)
flips the fixture's emitted launch.args from ["acp"] to ["--poisoned"]
at 60007fda4, command/env/policy_env unchanged. So: no test in the suite
can put a goose entry in the registry
via the custom path, and no preset currently carries one, so no interleaving
can perturb this fixture — containment with one checked leg and one
coincidental one. A future fixture built on a non-builtin runtime id has
no containment at all — it would be order-dependent against whatever
registry-writing test ran last and must take the lock.

Late instance, found while reviewing the mode guard. The guard exact-matches
respond_to untrimmed and case-sensitively, which is only correct if clap's
ValueEnum derive is case-sensitive. config.rs gives two answers: the derive
at :448-453 carries no ignore_case, while the crate's own tests call
RespondTo::from_str(s, true)ignore_case = true. Reading the source
supports either. Measured on the built binary instead: owner-only starts,
OWNER-ONLY / Owner-Only / ALLOWLIST / NOBODY all exit rc=2 invalid value. Case-sensitive at the CLI, so the guard is right — and right for a
reason the source does not state. The from_str(_, true) tests exercise a
different surface and are not evidence about the CLI.

Corollary, and the sharper half. When a test helper reimplements
production instead of calling it, the helper is a fork — and a fork can be
right while production is wrong, or wrong in the same way, and the suite
reports green either way. Both BUZZ_ACP_ALLOWED_* gates are forked like this:
production compares strings while the helpers compare post-parse enums
(config.rs:2623) or re-derive the split (buzz-cli/.../channels.rs:1296).
Production and the helper each carry their own copy of the empty-entry filter
(:1025 and :1300), so fixing one says nothing about the other. Measured on
buzz-cli, restoring byte-exact between runs:

tree result
baseline 274 passed
drop the empty-filter in production only (the real fix) 274 passed — no signal
drop it in the test helper only 273 passed, 1 failed (channels.rs:1338)

Two independent defects, stacked, and worse together than either alone:
production can be fixed with no test ever noticing, and the helper cannot be
corrected without a false alarm demanding the bug back. The root cause is one
bit of type information — check_allowed_channel_add_policy(allowed_raw: &str, ..) cannot represent "unset", while production reads env::var(..) -> Result,
where unset and "" are different states. A helper whose parameter type can't
represent all of production's input states isn't testing production's states —
it's testing a subset it silently chose. Same family as the struct-literal
descriptor and the fixture drift: the test and the thing it tests agreeing with
each other, rather than the test measuring the thing. Neither defect is in this
PR's diff (git diff --name-only 28ae6cd21 <head> -- crates/buzz-cli is
empty); both are now filed as NIP-34 issues on this repo: the fail-open +
fork-helper defect at issue event 0524a4113f2d97fd… and the respond-to
self-lock at e32837498969b5e7… (filed 2026-08-02 after Quinn measured that
no prior filing existed — zero hits on GitHub block/buzz open or closed
and zero on the relay's kind:1621 issues, against working positive
controls). The prescription was itself
mutation-tested before being written down: repairing the fork's signature
(Option<&str> + assertion → None) still let the reintroduced production
bug ship 274-green — an expressive fork is still a fork; it never executes
production. So the buzz-cli fix has three parts and one explicit keep:
drop the production filter; delete the helper and point its tests at the
real cmd_set_add_policy (which self-discriminates by error variant —
Usage = refused, Network(BadScheme) = passed the gate — no relay needed);
serialize the env-var tests behind one tokio::sync::Mutex::const_new
lock taken with .lock().await, including the pre-existing :1362
integration test (the fork was silently buying test isolation — without the
lock, parallel runs flake nondeterministically; a std::sync::Mutex held
across .await trips clippy::await_holding_lock under -D warnings); and
keep the then-dead !allowed.is_empty() clause with a comment saying
why. It is unreachable-false (split(',') never yields an empty vec), but it
is the only thing that keeps the reintroduced production bug detectable —
mutation-tested: on a tree that deletes the clause, reintroducing the
empty-filter bug survives 275/0, because ""/","/" " refuse either way
and the filter goes semantically inert. Dead code can be load-bearing for
tests: "provably unreachable" is an argument about behavior, never about
coverage. When a helper forks production, the fix has to delete the fork:
any change that leaves two implementations standing can only ever be
verified against the one the tests call. Final shape: the keep and the
broad lock are both artifacts of the fork surviving in some form. The
extraction variant (Dawn, mutation-tested at 60007fda4) removes the
tension: extract one check_channel_add_policy_allowed(Option<&str>, &str)
that production calls, with the Option placed at the env boundary
where the Result<String, VarError> bit actually lives. 5/6 mutants
killed; the empty-filter survivor is proven equivalent (exhaustive
6174-pair check, 0 divergences, with a diverging negative control;
independently re-derived by a second generator — different tokens and
shape — 0 divergences on admitted policies, 500 on a non-admitted control),
not a coverage hole — on a one-implementation tree there is no fork left to
witness, so no dead clause needs keeping. One scope line on that
equivalence: it is caller-conditional, a property of the only current
caller, not of the gate function — cmd_set_add_policy's own match at
:1027-1034 admits only three policies before the gate runs; a second
caller reaching the gate with arbitrary strings resurrects m1 as a real
hole. The lock does not disappear, it
narrows (Dawn's own correction, caught by Mari): lock exactly the tests
that mutate the process env — three-plus-one on a fork tree, two on the
extraction tree — behind one tokio::sync::Mutex, and the lock is part of
the assertion, not hygiene: with it deleted, the gate test fails 8/8 runs
deterministically by receiving Network(BadScheme) where it expects
Usage — the unset test's remove_var clobbers the other's set_var, and
the gate test passes straight through the gate, a false negative on the
exact authz assertion the test exists to make. State it as an outcome:
these two tests must not observe each other's env writes. 276/0 stable
across 5 parallel runs, clippy -D warnings clean; independently verified
(patch applied to a second worktree: result blob d67e584be matches the
patch index, full mutant matrix reproduces row for row). One new row no
earlier
prescription covered: collapsing unset into Some("") fails closed
an unconfigured deployment refuses every policy — killed by the unset test.
Patch: OUTBOX/BUZZ_CLI_ADD_POLICY_GATE_EXTRACT_FIX.patch. The filed issue
(0524a411…) carries the fork-shape prescription; whoever picks it up
should prefer the extraction shape, drop the dead-clause keep with it, and
keep part 3 outcome-shaped: serialize whichever tests mutate the env.

Verification (final HEAD 60007fda4)

  • Full touched-package suites at each integration merge (log in plan file).
    At candidate parent 00e5b5fe9: buzz-backend-kubernetes 154,
    buzz-acp 673, desktop tauri 2100+3, pnpm 3908, workspace clippy/fmt/tsc
    all clean. The only delta to 60007fda4 is one character in
    scripts/test-k8s-sprig-image-live.sh (heredoc escape so the readlink
    probe evaluates pod-side, not host-side at render); crates/ tree hash
    is byte-identical at both SHAs, so the Rust receipts attach by tree
    identity. buzz-backend-kubernetes suite re-run in-shell at
    HEAD == 60007fda4: 154 passed.
  • Adversarial one-HEAD gate (Sami): guard matrix 12/12, predicate mutants
    7/7, doomed-invocation finding closed end-to-end; tree-hash carry to
    60007fda4 confirmed (crates/buzz-backend-kubernetes blob unchanged).
  • Live-local pass per TESTING.md + skill-buzz-testing (Perci, at
    60007fda4): explicit docker-desktop context, digest-qualified image
    imported into node containerd k8s.io namespace, pull policy Never;
    pod printed DIGEST_ABI_OK, resolved_spec and image_id both the
    exact requested digest, script exit 0. Dedicated per-run namespace,
    ownership labels on every object, scoped cleanup verified empty after.
  • Implementation review (Wren) at 60007fda4: 9.6 minimalness /
    9.4 elegance / 9.3 correctness, no blocker.
  • origin/eva/k8s-backend == 60007fda4 (ls-remote verified; SHA
    identity is byte identity).

npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta and others added 21 commits August 1, 2026 23:09
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
…resence bound

Every file:line citation in docs/remote-agents.md was labeled against
`c1bca1b56` or `b4f4ed1a6`, neither of which is an ancestor of main
(merge-base with 28ae6cd is af4b708). Readers could not resolve them.

Rather than assume offsets, each cited line's content was extracted at the
old pin and located in the merged tree. 44 of 49 already resolved
byte-exactly at 28ae6cd — they had been verified against the PR base, not
the labeled pin, so the caveat paragraph had the accuracy backwards.

Three drifted as pure offsets, corrected here:

  discovery.rs:74-193 -> 75-207   KNOWN_ACP_RUNTIMES; all four cited
                                  metadata fields occur only in this range
  runtime.rs:1006     -> 1001     spawn_agent_child(.., false, ..); the
                                  eager/lazy contrast with restore.rs:333
                                  (true) still holds
  types.rs:809        -> 814      DEFAULT_AGENT_PARALLELISM, still 10, so
                                  the ~87s / ~197s arithmetic is unchanged

One drift was not an offset but a factual error. PRESENCE_TTL_SECS is 180,
not 90: bf139e8 (#3783, Jul 30 15:26) raised it to preserve a
three-heartbeat window after the desktop heartbeat moved to 60s, landing
after this spec's base (Jul 30 12:30) but before it merged. The spec merged
onto a main that had already invalidated the number. Pinned by test at
presence.rs:114-115.

90s was load-bearing in three places, all understating the real bound by 2x
(I3 at :206, the BUZZ_ACP_NO_PRESENCE argument at :216, the same argument
for I5 at :928), and the spec quotes the vision's "ninety seconds" prose
inline. Corrected in both documents. I3's promise is that the staleness
window is bounded, never indefinite; the width was inherited from a
relay-wide constant that moved for unrelated perf reasons, and the binding's
grace period targets the avoidable half either way.

Each Known Defect claim was re-verified live at 28ae6cd rather than
relabeled — a re-pin carrying a stale assertion forward is worse than the
offset it fixes. All eight still hold: KD1 bare strip_prefix at
backend.rs:467, KD3 deploy_payload_json emits 15 flat fields with no launch
block, KD4 BUZZ_ACP_EXIT_AFTER_INACTIVITY has zero occurrences, KD5
provider_deploy invokes deploy with no preceding info, KD8's empty-string
arm in coerceConfigValues.

Docs-only. No behavior change.

Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
…d env keys + KD8

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* max/k8s-reaper:
  Add ACP inactivity self-termination

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
The provider binary behind the remote-agents spec's Kubernetes binding
(`docs/remote-agents.md` §K8s). One process per operation: one JSON
object in on stdin, one JSON object out on stdout, exit 0 for any
response that was produced. `info` and `deploy`; kube-rs against an
ambient kubeconfig only, so no credential ever appears in
`provider_config` (I2).

The §Deploy State Machine is implemented row by row against a
`Substrate` trait, with the real kube-rs implementor in `cluster.rs`
and a scripted fake driving the shipped `deploy` in tests. Three
mechanisms are normative rather than incidental and are spelled once
each: quorum reads with `resourceVersion` left unset, UID +
`resourceVersion` delete preconditions, and `Status.reason` as the 409
discriminator — `code == 409` appears nowhere in the crate, because
`AlreadyExists` and `Conflict` share it and conflating them takes the
adoption path on a failed delete (`:763-765`).

Secrets are per-attempt, immutable, and uniquely named, which makes
payload and Secret atomic at the pod-spec boundary without a Lease.
GC never touches an object lacking the management marker, and its
orphan sweep is skipped entirely when the apiserver's `Date` header is
absent — a fast local clock must not be able to delete an in-flight
Secret.

144 tests. The fake substrate carries a fake clock, which turned two
hot-spin bugs in the reconcile loop from unreproducible hangs into
instant failures. Per-MUST coverage, including what is deliberately out
of scope with its citation, is in the PR body.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
* dawn/k8s-provider:
  feat(k8s): Kubernetes backend provider crate

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* dawn/k8s-provider:
  feat(k8s): Kubernetes backend provider crate

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…tter

`deploy-full-launch.request.json` was derived by reading the desktop's
emitters rather than executing them, and carried four values no desktop
can produce: `respond_to` held a pubkey where `RespondTo` serializes a
kebab-case enum; the allowlist entries and `launch.owner_pubkey` failed
`validate_respond_to_allowlist`'s 64-hex rule (`types.rs:897`, re-checked
on every spawn at `runtime.rs:385`); `policy_env` used an invented
`BUZZ_ACP_PARALLELISM` where the emitter writes `BUZZ_ACP_AGENTS`
(`runtime.rs:729`, read back by `buzz-acp` `config.rs:292`); and
`launch.env` held a `LAYERED` key belonging to no layer of
`resolve_effective_harness_descriptor`.

The agent object is now transcribed verbatim from a run of the real
`build_launch_block` -> `deploy_payload_json` path, so it is the complete
emitted shape — including the always-serialized top-level keys with their
null values, whose absence from `policy_env` is what proves them null.

This provider is indifferent to every one of those fields, which is why
its own tests could not notice: `respond_to` is an `Option<String>`, the
allowlist an opaque `Vec<String>`, `policy_env` an arbitrary map, and
`the_full_desktop_payload_is_accepted` passes on invented data exactly as
happily as on recorded data. The README now says so, and points at the
desktop's whole-object equality test as the enforcement — a completeness
guard stops a case from going missing, it cannot tell you a case is false.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
… emitter

* dawn/k8s-provider:
  test(k8s): record the full-launch wire fixture from the desktop's emitter

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* origin/dawn/k8s-provider:
  test(k8s): record the full-launch wire fixture from the desktop's emitter

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
The local spawn path re-validates the respond-to gate before spawning,
and says why in its own comment: "doing it here means we never spawn a
doomed process" (`runtime.rs:378`). The deploy path projects
`record.respond_to` / `record.respond_to_allowlist` straight through
(`agents_deploy.rs:180-181`), and this provider emitted each key gated
only on its own field being non-empty — so an allowlist mode with no
list, or an entry that is not 64 hex characters, reached the pod.

The harness refuses both at config parse (`config.rs:996-1004,629-641`),
which exits 1. Under `restartPolicy: Never` that is Terminated ->
Delete -> recreate, and the Delete row takes the pod but not its Secret;
only a later deploy's orphan sweep reaps those. Sami measured the
resulting churn through the shipped reconciler: 300 creates, 0 in-call
Secret deletes, ending in "startup not confirmed" — indistinguishable
from a slow cluster. One condition here turns that into one message.

Mirrors the harness's rules including their asymmetry: the allowlist is
validated only in allowlist mode and merely warned about otherwise.
Validating it in every mode would refuse a deploy whose identical local
spawn succeeds, so `a_junk_allowlist_is_tolerated_outside_allowlist_mode`
pins that deliberately rather than leaving it to look like an oversight.

Mutation-tested rather than asserted: dropping the call, the empty-list
clause, or the hex clause each fails only the test that names it (2/1/1
failures), and refusing unconditionally fails the positive control — so
no test here passes for a reason other than the one it states.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…at startup

* dawn/k8s-provider:
  feat(k8s): refuse a respond-to gate the harness rejects at startup

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* origin/dawn/k8s-provider:
  feat(k8s): refuse a respond-to gate the harness rejects at startup

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
…oviders

* mari/k8s-desktop:
  feat(desktop): integrate Kubernetes backend providers

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* origin/mari/k8s-desktop:
  feat(desktop): integrate Kubernetes backend providers

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
The merged guard validated the allowlist but projected the mode string
straight through. `respond_to` is an opaque `Option<String>` on the wire
and a `clap::ValueEnum` at the harness (`config.rs:448-454`), so an
unrecognized mode dies at rc=2 — before config parsing runs, earlier
than the allowlist errors the guard already covers. Same ending as
before: exit 1 under `restartPolicy: Never` is Terminated -> Delete ->
recreate, one Secret leaked per attempt.

Found by Sami's acceptance audit, which measured the harness's real
surface instead of reading it. Re-running his matrix against the shipped
predicate: 8 of 10 rows already agreed, and the 2 that disagreed were
both this — `respond_to: "npub1abc"` and a padded `" allowlist "`.

Matched exactly and untrimmed, because clap does not trim: `" allowlist "`
is rc=2 at the harness even though the trimmed string is valid, so
trimming here would accept a deploy the harness refuses.

Validated against the harness's four modes, not the desktop's three.
`parse_wire` rejects `nobody` deliberately (`managed_agents/types.rs:871-880`)
but the harness starts fine with it; this guard exists to cover
non-desktop callers, so inheriting that narrowing would refuse a working
launch. `every_mode_the_harness_accepts_is_deployable` pins all four.

The matrix also runs through the built binary in `wire_fixtures.rs`.
`build_env` precedes `client::connect` (`main.rs:124` before `:132`), so
under an impossible KUBECONFIG the error string is the ordering
assertion: "kubeconfig" means the gate passed and we reached the cluster,
"deploy refused" means we stopped before writing a Secret. Asserting only
`ok:false` would pass on the connection error and prove nothing.

Mutation-tested. Dropping the mode check fails both negatives; trimming
before the match fails the padded case only; dropping `nobody` to mirror
the desktop fails the positive control only — each test failing for the
reason it names, control 148+4 green.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Quinn found that the fixture's `agent` half was executed but its `launch`
half was not: `agents_tests.rs:447-455` built an
`EffectiveHarnessDescriptor` as a struct literal and asserted the fixture
against it, so `command`, `args` and `env` were pinned to values the test
itself wrote down. The same literal sat on both sides of the `assert_eq!`
— consistency, not correctness, and the fourth instance this arc of a
value that agrees with itself.

Mari then executed `resolve_effective_harness_descriptor` against a
coherent source record (`runtime: "goose"` plus model/provider/env_vars,
so the resolver sees what the payload serializer sees). Everything
matched the committed fixture except one field:

    launch.args: ["run", "--session"] -> ["acp"]

Transcribed from her executed result, not derived — the README's own rule
is that "recorded" means executed and transcribed, and `launch` was the
one field group still exempt from it.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…ch.args

* dawn/k8s-provider:
  test(k8s): transcribe launch.args from the resolver, not a test literal
  feat(k8s): refuse respond-to modes the harness cannot parse

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* origin/dawn/k8s-provider:
  test(k8s): transcribe launch.args from the resolver, not a test literal
  feat(k8s): refuse respond-to modes the harness cannot parse

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
…, and resolver-backed fixture test

* perci/k8s-image-tests:
  test(desktop): pin staged provider bytes
  test(k8s): cover provider release and raw list seams
  test: add safe Sprig Kubernetes preflight
  build: add Sprig agent image
  test(desktop): resolve the fixture launch descriptor

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>

* origin/perci/k8s-image-tests:
  test(desktop): pin staged provider bytes
  test(k8s): cover provider release and raw list seams
  test: add safe Sprig Kubernetes preflight
  build: add Sprig agent image
  test(desktop): resolve the fixture launch descriptor

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner August 2, 2026 10:47
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d added 2 commits August 2, 2026 07:32
Agents need an HTTP client by default; libcurl was already present via
git's dependency chain, so adding the curl binary costs ~0.2 MB
(17.1 -> 17.3 MB). Image contract test passes against the rebuilt image.

Requested by Tyler on PR #4289.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Multi-arch (amd64+arm64 on native runners, digest-push + manifest merge,
provenance attestation) mirroring docker.yml's proven pattern. Triggers:
main pushes (paths-filtered), sprig-v* tags (semver family, shared with
the sprig binary release), PRs build-only.

First push creates the GHCR package private; an org admin flips
ghcr.io/block/buzz-sprig to public once (noted in the workflow header).

Per Tyler on PR #4289: publish the sprig image publicly.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Comment thread .github/workflows/sprig-image.yml Fixed
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d added 3 commits August 2, 2026 09:13
…f-version-mismatch

The SHA df4cb1c0 is actions/checkout v6.0.3; the floating v6 tag has since
moved, so '# v6' no longer matches the pinned commit. Comment now names the
immutable tag, matching the style of every other pin in the file.
(docker.yml on main carries the same latent '# v6' comment on the same SHA —
zizmor only scans new code; noting for a trivial follow-up.)

Resolves code-scanning alert 191 on PR #4289.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…sion-mismatch)

Repo-wide sweep of the same defect zizmor flagged on sprig-image.yml:
every actions/checkout@df4cb1c0 pin commented '# v6' actually pins
v6.0.3 (the floating v6 tag has moved to d23441a4), and helm-chart's
'# v4' on 34e11487 is v4.3.1. Comment-only change, 32+3 lines across
7 files, verified against the GitHub tags API. Two uncommented pins
in push-gateway-helm-chart.yml carry no claim and are left as-is.

Per Tyler on PR #4289 ('fix it').

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Live red-team (Wren) found a release blocker: a deterministic startup
failure — the harness starts, rejects its configuration, and exits (e.g.
idle_timeout 900s vs BUZZ_ACP_MAX_TURN_DURATION 180s) — landed in the
Terminated classification row, which fence-deleted the pod, re-entered,
created again, and minted a fresh immutable Secret every ~4s poll for the
whole 600s deadline: 107 Secrets measured in one call, every one younger
than the 1200s orphan age gate, so GC could not arrest the growth. A
bounded-call resource DoS and nsec-bearing-Secret amplifier.

Root cause is a spec gap, not an implementation slip: the replacement rows
(terminated / provably broken / divergent) were written for residue from a
previous life and never distinguished "residue I found" from "the attempt
this call just made".

The fix: the deploy loop now tracks whether this call created its pod.
A replace-classification after our own create returns the in-band error —
carrying the container's exit code and reason (message redacted, same rule
as pull messages) — instead of delete-recreating. The failed pod and
Secret are deliberately left as evidence; the next Start's preflight GC
collects them together before its own single attempt, so retry is gated on
fresh owner intent and litter is bounded at one pod + one Secret per
press, not per poll. Residue from previous lives is still replaced exactly
once on the way to this call's attempt; the recoverable/observe rows are
untouched, so no new age or backoff clock exists to collide with the
Cluster Autoscaler.

observe::condition gains a terminated arm (exit code + reason only) so the
error names the actual failure instead of "the pod is Failed".

docs/remote-agents.md gains the normative "one create attempt per call"
rule; all spec line-number citations in the crate are re-pinned for the
25-line shift and spot-verified against the cited text.

Verification:
- New conformance tests: report-not-hot-replace (Secret count exactly 1;
  fails at 300 Secrets with the guard disabled), revive-path-preserved
  (one delete + one create), cross-call cleanup (two Starts => total one
  pod + one Secret).
- cargo test -p buzz-backend-kubernetes: 153 + 4 green.
- cargo test -p buzz-acp: 664 + 9 green.
- Live repro on docker-desktop with Wren's exact inconsistent payload:
  in-band error naming exit code 1, exactly 1 pod + 1 Secret after the
  call (was 107 Secrets); second call collected the residue and left
  1 pod + 1 Secret again. Evidence:
  WORK_LOGS/REMOTE_AGENTS_CHURNFIX_2026_08_02/.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
@tlongwell-block
tlongwell-block merged commit 6530b58 into main Aug 2, 2026
40 checks passed
@tlongwell-block
tlongwell-block deleted the eva/k8s-backend branch August 2, 2026 18:39
tlongwell-block added a commit that referenced this pull request Aug 2, 2026
…t sprig image to published digest (#4392)

## What

Two changes, both fallout/follow-up from #4289 landing:

### 1. Fix the Security job failing on main (lockfile-only)

Eight RUSTSEC advisories published today against the nostr stack turned
`cargo-deny check` advisories red on main ([failing
run](https://github.com/block/buzz/actions/runs/30761611723/job/91533106673)).
Not introduced by #4289 — the advisories landed upstream and any push to
main today would have tripped them.

- **RUSTSEC-2026-0225..0230** → `nostr` 0.44.6 → **0.44.7** (Debug
output exposing NIP-46/NIP-60 credentials; wallet parsers accepting
unauthenticated events; NIP-44/NIP-04/NIP-98 resource exhaustion; NIP-50
empty-filter panic)
- **RUSTSEC-2026-0231..0232** → `nostr-relay-pool` 0.44.2 (root) /
0.44.1 (tauri) → **0.44.3** (auth-challenge memory exhaustion;
processing of unverified relay events)

Both workspace lockfiles bumped (`Cargo.lock`,
`desktop/src-tauri/Cargo.lock`). No manifest changes.

### 2. Default the desktop GUI's sprig image to the published
`ghcr.io/block/buzz-sprig`

The first main-push after #4289 published the image publicly (package
created 18:44Z, visibility `public`). The `config_schema()`'s `image`
property now carries a `default`:

```
ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76
```

**Why tag+digest, not tag:** the backend deliberately rejects tag-only
references — the pod runs with the agent's nsec and tags are mutable
pointers (`image.rs` §Image). The tag+digest form keeps the
human-traceable `sha-6530b58` while the digest does the pinning;
`image::parse` already normalizes it to the tagless canonical form, so
create-intent fingerprints are identical to the bare-digest spelling.
The digest is the **multi-arch manifest-list digest** (amd64+arm64),
resolved via `docker buildx imagetools inspect`.

**This is a UI prefill, not a baked fallback:** `image` stays in the
schema's `required` list, an empty value still fails closed with a named
field, and the desktop submits the value explicitly in `provider_config`
(the `WhereToRunSection` probe seeds `providerConfig` from schema
defaults) — so deploy fingerprints never depend on compiled-in provider
state, and the spec's §K8s pod-reconciliation concern about
baked-default divergence is not engaged. Module prose that said "no
published image exists yet" is updated to match reality.

No desktop code changes needed: the form already prefills from
`properties[*].default` and submits seeded defaults.

## Testing

- `cargo-deny check` at head: **advisories ok, bans ok, licenses ok,
sources ok** (was: advisories FAILED)
- `cargo test -p buzz-backend-kubernetes`: **158 passed** (154 lib + 4
wire), including new `schema_default_image_round_trips_through_parse`
pinning the constant + its normalization, and the wire `info` test now
asserting the default is present in the provider's real stdout response
- Live provider probe: `{"op":"info"}` against the built binary returns
the default in `config_schema.properties.image.default` with `required`
unchanged (`["namespace","image"]`)
- Full workspace test suite via pre-push hook: green (earlier direct
`cargo test --workspace` run: sole failure was
`api::mesh_demo::demo_join_forwarded_arm_round_trips_echo`, the
documented pre-existing main flake — unrelated, fails on base)
- Image existence verified against GHCR: `docker buildx imagetools
inspect ghcr.io/block/buzz-sprig:sha-6530b58` resolves to the pinned
manifest-list digest with linux/amd64 + linux/arm64 manifests

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
tlongwell-block added a commit that referenced this pull request Aug 3, 2026
…g keystrokes (#4411)

## What

Fixes the create-agent dialog's "Run on" provider config fields eating
keystrokes — reported by Tyler in buzz-remote-agents (channel
`29414326`, thread `db76677a`): the Kubernetes **Kubeconfig context**
field would not accept typing.

## Why it happened (the Typewriter Eraser, shipped in #4289)

`WhereToRunSection`'s probe `useEffect` depended on the whole `draft`:

1. every keystroke changed the draft → effect re-fired → provider binary
re-probed;
2. each probe result is a fresh object written into the draft → the
effect re-triggered **itself**, respawning the provider binary in a loop
for as long as the dialog sat on a provider;
3. every probe resolution reset `providerConfig` to schema defaults —
erasing whatever was typed. A field with no schema default (`context`)
snapped back to empty, i.e. "won't let me type". Unrelated to how many
kubeconfig contexts you have.

## Fix

- **Probe once per provider selection**, keyed on the provider's stable
`binaryPath` — not the draft, not the provider object (a
`useBackendProvidersQuery` refresh must not reprobe an unchanged
selection).
- **Latest-state resolution** via `React.useEffectEvent` + a new pure
`applyProbeResult` helper: schema defaults merge **beneath** the current
`providerConfig`, so a probe landing after the user typed can never
clobber in-flight input (per Wren's pre-patch red-team: changing deps
alone leaves a stale closure).

Existing `cancelled` cleanup keeps provider-switch/unmount safe;
selection reset (`emptyWhereToRunDraft`) and the fail-closed probe-error
path are unchanged.

## Tests

- **Unit** (`whereToRunIntent.test.mjs`): `applyProbeResult` merge
semantics — defaults under typed values, user-cleared fields stay
cleared, schema-less results, unrelated fields preserved.
- **E2E** (new `where-to-run-config.spec.ts`, added to the smoke
project, **red-first verified**: all 3 fail against the unfixed
component):
- typing into a defaultless provider field sticks, and
`probe_backend_provider` fires exactly once per selection;
- the config form is gated on probe resolution (slow probe: no
half-rendered form, defaults prefill once);
  - provider → local → provider re-probes and resets cleanly.
- Mock bridge gains `backendProviders` / `backendProviderProbeResult` /
`backendProviderProbeDelayMs` seams (defaults preserve prior behavior).

## Verification at 8eb7680

- `pnpm check` + `tsc` clean, `pnpm test` 3926/3926;
- new spec 3/3 green (and 3/3 red on the unfixed component);
- pre-push lefthook: desktop-test, desktop-check, desktop-tauri-checks,
rust-tests, mobile-test all green.

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
justinharkelroad pushed a commit to justinharkelroad/buzz that referenced this pull request Aug 3, 2026
…g keystrokes (block#4411)

## What

Fixes the create-agent dialog's "Run on" provider config fields eating
keystrokes — reported by Tyler in buzz-remote-agents (channel
`29414326`, thread `db76677a`): the Kubernetes **Kubeconfig context**
field would not accept typing.

## Why it happened (the Typewriter Eraser, shipped in block#4289)

`WhereToRunSection`'s probe `useEffect` depended on the whole `draft`:

1. every keystroke changed the draft → effect re-fired → provider binary
re-probed;
2. each probe result is a fresh object written into the draft → the
effect re-triggered **itself**, respawning the provider binary in a loop
for as long as the dialog sat on a provider;
3. every probe resolution reset `providerConfig` to schema defaults —
erasing whatever was typed. A field with no schema default (`context`)
snapped back to empty, i.e. "won't let me type". Unrelated to how many
kubeconfig contexts you have.

## Fix

- **Probe once per provider selection**, keyed on the provider's stable
`binaryPath` — not the draft, not the provider object (a
`useBackendProvidersQuery` refresh must not reprobe an unchanged
selection).
- **Latest-state resolution** via `React.useEffectEvent` + a new pure
`applyProbeResult` helper: schema defaults merge **beneath** the current
`providerConfig`, so a probe landing after the user typed can never
clobber in-flight input (per Wren's pre-patch red-team: changing deps
alone leaves a stale closure).

Existing `cancelled` cleanup keeps provider-switch/unmount safe;
selection reset (`emptyWhereToRunDraft`) and the fail-closed probe-error
path are unchanged.

## Tests

- **Unit** (`whereToRunIntent.test.mjs`): `applyProbeResult` merge
semantics — defaults under typed values, user-cleared fields stay
cleared, schema-less results, unrelated fields preserved.
- **E2E** (new `where-to-run-config.spec.ts`, added to the smoke
project, **red-first verified**: all 3 fail against the unfixed
component):
- typing into a defaultless provider field sticks, and
`probe_backend_provider` fires exactly once per selection;
- the config form is gated on probe resolution (slow probe: no
half-rendered form, defaults prefill once);
  - provider → local → provider re-probes and resets cleanly.
- Mock bridge gains `backendProviders` / `backendProviderProbeResult` /
`backendProviderProbeDelayMs` seams (defaults preserve prior behavior).

## Verification at 8eb7680

- `pnpm check` + `tsc` clean, `pnpm test` 3926/3926;
- new spec 3/3 green (and 3/3 red on the unfixed component);
- pre-push lefthook: desktop-test, desktop-check, desktop-tauri-checks,
rust-tests, mobile-test all green.

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
(cherry picked from commit 2c0ac24)
Signed-off-by: Justin <justin@triumphfw.com>
joahg added a commit to joahg/buzz-dev-mode that referenced this pull request Aug 3, 2026
…-style

* origin/main:
  Polish mobile composer and messaging UI (block#3918)
  ci(linux): enable mesh-llm feature in Linux release and canary builds (block#4524)
  fix(desktop): stop the create-agent provider config probe from erasing keystrokes (block#4411)
  fix(mobile): recover and pace live subscriptions (block#3053)
  feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp (block#4395)
  fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest (block#4392)
  fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures (block#3778)
  feat(k8s): Kubernetes backend plugin + desktop deploy path (block#4289)
  fix(git): allow deleting the default branch (block#4297)
  feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) (block#4020)
  docs: formal spec for remote agents and their management (block#3748)
  fix(nip-oa): accept raw Nostr tag form in parse_json_array (block#4203)
  perf(relay): serve relay-membership checks from the read replica (block#4124)
  chore(deps): bump nostr-relay-pool for RUSTSEC-2026-0224 (block#4139)
  docs(nostr): document #h requirement for live reaction subscriptions (block#3487)

Signed-off-by: Joah Gerstenberg <joah@squareup.com>
wpfleger96 pushed a commit that referenced this pull request Aug 3, 2026
…ed-unread

* origin/main: (44 commits)
  chore(release): release Buzz Desktop version 0.5.4 (#4562)
  test(mobile): assert follow boundary semantics (#4559)
  docs(release): align desktop handoff instructions (#3988)
  fix: report agent usage per provider round, not once per turn (#4545)
  fix(desktop): harden Windows installs against Defender block and orphaned Node (#4382)
  feat(desktop): improve channel template discovery (#4549)
  fix(desktop): save key backups to authorized path (#4022)
  Add channel activity hover menu (#3935)
  feat(desktop): show saved Run on settings when editing an agent (#4539)
  fix(desktop): disambiguate provider API key labels and annotate mint key (#4406)
  fix(desktop): make OpenAI key re-enterable after first save in card mint dialog (#4140)
  fix(config-bridge): add harness-definition env tier and fix equal-value model override (#3580)
  Polish mobile composer and messaging UI (#3918)
  ci(linux): enable mesh-llm feature in Linux release and canary builds (#4524)
  fix(desktop): stop the create-agent provider config probe from erasing keystrokes (#4411)
  fix(mobile): recover and pace live subscriptions (#3053)
  feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp (#4395)
  fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest (#4392)
  fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures (#3778)
  feat(k8s): Kubernetes backend plugin + desktop deploy path (#4289)
  ...

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
troyhoffman-oss added a commit to troyhoffman-oss/buzz that referenced this pull request Aug 3, 2026
Makes the provider deployable by a bone-stock desktop (0.5.4), which
ships upstream's block#4289 deploy path. That path resolves the harness
descriptor on the desktop and sends it in the payload as a `launch`
block; this crate was still reading the legacy top-level fields, which
the same desktop now populates only for display.

The breaking one is `launch.args`. Upstream's recorded wire fixture has
`agent_args: []` alongside `launch.args: ["acp"]` — the desktop leaves
the legacy field empty precisely when the args should be re-resolved
from the harness definition. Reading it wrote `BUZZ_ACP_AGENT_ARGS=""`,
so Goose would have launched with no `acp` subcommand: a harness that
starts, speaks the wrong protocol, and never answers.

Also consumed, each a silent behavior loss against the local spawn:

- `launch.command` — the resolved command, honoring an explicit override
  and the record's or persona's runtime id, over the denormalized copy.
- `launch.env` — the full six-layer resolution. It SUPERSEDES the legacy
  `env_vars` rather than merging over it (spec §Launch data tier 2);
  merging would resurrect layers the desktop already collapsed.
- `launch.policy_env` — session title, team instructions, MCP hook
  wiring, per-runtime defaults. Written below user env so an override
  still wins, as it does locally.
- `launch.owner_pubkey` — `BUZZ_ACP_AGENT_OWNER`, the legacy `!shutdown`
  path for records minted before `auth_tag`. A payload resolving neither
  is now refused rather than deployed into an agent nobody can stop.

Two refusals move earlier for the same reason, that this unit is
`Restart=always` with `RestartPreventExitStatus=78`: a respond-to mode
the harness rejects at config parse, and a policy tier carrying a
provider-owned key, would each respawn every five seconds forever on a
host nobody is watching. `BUZZ_ACP_NO_PRESENCE` joins the reserved list —
presence is the only signal a remote agent is alive, and there is no
authoritative value to overwrite a suppression with.

The push seams (`buzz_acp_binary`, `buzz_cli_binary`) are this fork's
addition and absent from a stock payload, so each falls back to this
process's own environment — the same variable, reached from the other
side of the spawn.

Unit convergence is preserved: the slug remains a function of
(`agent.name`, derived pubkey) alone, so a stock-driven redeploy of an
already-deployed agent updates its unit instead of standing a duplicate
beside it. Pinned by a test that mutates every launch field and asserts
the slug does not move.

Tests are driven from byte-for-byte copies of upstream's golden wire
fixtures, which upstream recorded from the desktop's real payload path
rather than deriving from source — the provenance is what makes the
empty `agent_args` in them trustworthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants