feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "buzz-backend-kubernetes"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)"
[[bin]]
name = "buzz-backend-kubernetes"
path = "src/main.rs"
[dependencies]
kube = { workspace = true }
k8s-openapi = { workspace = true }
nostr = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
rand = { workspace = true }
chrono = { workspace = true }
http = "1"
http-body-util = "0.1"
# Explicit rustls dep with the ring provider — required to install the
# process-level CryptoProvider at startup. Without it this binary panics on its
# first TLS connection to the apiserver: the release build compiles every
# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies
# both ring and aws-lc-rs features and leaves rustls unable to auto-select a
# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
[dev-dependencies]
tower = { workspace = true }
@@ -0,0 +1,377 @@
//! The deploy state machine (spec §Deploy State Machine), as a pure function.
//!
//! `classify` maps a verified observation plus the desired create intent to
//! one [`Action`]. It performs no I/O, so every row of the spec's table is a
//! unit test with no cluster. `reconcile` executes actions and re-enters.
//!
//! Two invariants are structural rather than remembered:
//!
//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that
//! authorized it. There is no way to build a delete without one, so a later
//! helper cannot re-read and silently substitute a fresher fence.
//! * The pull-failure classifier ([`PullFailure`]) reaches only
//! [`Action::Report`] and [`Action::Observe`]. It is absent from
//! `Action::Delete`'s type, so "reason strings are never deletion
//! authority" is enforced by the compiler.
use crate::intent::Fingerprint;
/// The compare-and-delete fence: UID + resourceVersion from the observation
/// that authorized the deletion. A failed precondition means the object
/// changed since the read — re-enter, never retry the delete.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fence {
pub uid: String,
pub resource_version: String,
}
/// Why a pod that never started looks permanently broken. Reporting only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PullFailure {
/// Registry auth: a 403/401 `ImagePullBackOff` retries forever without
/// ever succeeding, so "the pull retries" is false for this case.
Unauthorized,
/// The digest or repository does not exist at that registry.
ManifestUnknown,
/// The image has no variant for the node's architecture.
ArchMismatch,
}
/// The container's startup state, already decoded from pod status. Decoding
/// happens at the edge so this module stays free of API types.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Startup {
/// `state.running` — the harness process is up. This, not pod phase, is
/// what "live" means.
Started,
/// Started once and reached a terminal phase (Succeeded/Failed).
Terminated,
/// Never started, and self-healing is plausible: unschedulable during
/// scale-from-zero, an image pull in progress, a transient
/// `CreateContainerConfigError` whose Secret exists.
NeverStartedRecoverable,
/// Never started, and the provider *verified* the cause — not a reason
/// string. Either the referenced Secret is confirmed absent by a
/// most-recent read, or the image reference is structurally invalid.
NeverStartedProvablyBroken,
/// Never started; the pull is failing in a way that will not self-heal.
/// Still recoverable in the *never delete* sense — this only changes what
/// we report and how long we wait.
NeverStartedPullFailing(PullFailure),
}
/// A pod that passed identity and ownership verification: label-selected,
/// full-pubkey annotation equal to the derived pubkey, management marker
/// present. Constructing this type is the verification step's output, so an
/// unverified object cannot reach `classify` at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedPod {
pub name: String,
pub fence: Fence,
/// Set once the apiserver accepts a delete. In Kubernetes there is no
/// `Terminating` phase — a pod being gracefully deleted stays in phase
/// `Running` for its whole grace period — so this must be checked
/// *before* startup state or the dying pod reads as the no-op row.
pub deletion_marked: bool,
pub startup: Startup,
/// The `buzz.block.xyz/create-intent` annotation as recorded at create.
/// `None` for a pod written before the annotation existed, which counts
/// as divergence.
pub recorded_intent: Option<Fingerprint>,
}
/// What the reconciler should do next.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Create the pod, then wait for the harness container to start.
Create,
/// Compare-and-delete, poll for actual disappearance, then re-enter.
Delete { name: String, fence: Fence },
/// Wait for a deletion already in flight, then re-enter.
AwaitDisappearance { name: String },
/// Strict no-op: return this `agent_id`, mutate nothing.
NoOp { agent_id: String },
/// Keep observing until started or the operation deadline expires; on
/// expiry report the latest condition. Never deletes, on this call or any
/// later one.
Observe { name: String },
/// Surface an actionable condition immediately rather than burning the
/// deadline on a failure that will not self-heal.
Report { name: String, failure: PullFailure },
}
/// Apply the spec's ordered rules to one verified observation.
///
/// `desired` is the freshly computed create intent; comparison is always
/// recorded-annotation vs freshly-computed, never a diff against the live pod
/// (admission defaulting would make every pod look divergent).
pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action {
let Some(pod) = observed else {
// Row: no instance → create. First deploy, or after GC.
return Action::Create;
};
// Row: deletion-marked, ANY phase. Checked before startup state because
// there is no `Terminating` phase to match on — a gracefully deleting pod
// reports phase `Running` throughout its grace period, so testing startup
// first would mistake it for the live no-op row and return an id that
// evaporates.
if pod.deletion_marked {
return Action::AwaitDisappearance {
name: pod.name.clone(),
};
}
match &pod.startup {
// Row: live and started → strict no-op. Start must never kill a live
// agent mid-turn, whatever the fingerprint says.
Startup::Started => Action::NoOp {
agent_id: pod.name.clone(),
},
// Row: terminated → delete residue, then re-enter to create. This is
// the normal restart path — how a user revives a reaped agent.
Startup::Terminated => Action::Delete {
name: pod.name.clone(),
fence: pod.fence.clone(),
},
// Row: never started, provably non-recoverable → fenced replace.
// "Provably" means a verified absence or a structural defect, never a
// reason string.
Startup::NeverStartedProvablyBroken => Action::Delete {
name: pod.name.clone(),
fence: pod.fence.clone(),
},
// Inside the recoverable row: a pull that will not self-heal is
// reported immediately instead of consuming the 600s deadline. This
// changes reporting and wait behavior only — no delete authority.
Startup::NeverStartedPullFailing(failure) => Action::Report {
name: pod.name.clone(),
failure: *failure,
},
// Row: never started, recoverable — split on create-intent
// divergence. Divergence is evidence of a config change the user is
// waiting on, and it is the *only* thing that replaces a
// never-started pod. Pod age triggers nothing: any finite age
// threshold collides with Cluster Autoscaler's own pod-age delays,
// and delete-recreate resets exactly the age it keys on.
Startup::NeverStartedRecoverable => {
if pod.recorded_intent.as_ref() == Some(desired) {
Action::Observe {
name: pod.name.clone(),
}
} else {
Action::Delete {
name: pod.name.clone(),
fence: pod.fence.clone(),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(seed: &str) -> Fingerprint {
Fingerprint::for_test(seed)
}
fn pod(startup: Startup, intent: Option<Fingerprint>) -> VerifiedPod {
VerifiedPod {
name: "buzz-agent-abc123def456".into(),
fence: Fence {
uid: "uid-1".into(),
resource_version: "rv-1".into(),
},
deletion_marked: false,
startup,
recorded_intent: intent,
}
}
#[test]
fn no_instance_creates() {
assert_eq!(classify(None, &fp("a")), Action::Create);
}
#[test]
fn started_pod_is_strict_no_op() {
let p = pod(Startup::Started, Some(fp("a")));
assert_eq!(
classify(Some(&p), &fp("a")),
Action::NoOp {
agent_id: p.name.clone()
}
);
}
/// The asymmetry the spec states plainly: an edit cannot reach a started
/// pod until it exits, but it *can* reach a never-started one — the
/// never-started pod is the one the user is editing because it did not
/// start.
#[test]
fn started_pod_no_ops_even_when_intent_diverges() {
let p = pod(Startup::Started, Some(fp("old")));
assert_eq!(
classify(Some(&p), &fp("new")),
Action::NoOp {
agent_id: p.name.clone()
}
);
}
/// In Kubernetes a gracefully deleting pod stays in phase `Running`. If
/// the deletion mark were checked after startup state, this pod would
/// take the no-op row and `deploy` would return an id that evaporates.
#[test]
fn deletion_mark_beats_every_startup_state() {
for startup in [
Startup::Started,
Startup::Terminated,
Startup::NeverStartedRecoverable,
Startup::NeverStartedProvablyBroken,
Startup::NeverStartedPullFailing(PullFailure::Unauthorized),
] {
let mut p = pod(startup.clone(), Some(fp("a")));
p.deletion_marked = true;
assert_eq!(
classify(Some(&p), &fp("a")),
Action::AwaitDisappearance {
name: p.name.clone()
},
"deletion mark ignored for {startup:?}"
);
}
}
#[test]
fn terminated_pod_is_replaced() {
let p = pod(Startup::Terminated, Some(fp("a")));
assert_eq!(
classify(Some(&p), &fp("a")),
Action::Delete {
name: p.name.clone(),
fence: p.fence.clone()
}
);
}
/// A never-started winner is repairable: pod exists, Secret confirmed
/// absent, container never started. A later deploy must delete-recreate
/// rather than no-op — the test that pins started-not-phase as the no-op
/// criterion.
#[test]
fn provably_broken_never_started_pod_is_replaced() {
let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a")));
assert_eq!(
classify(Some(&p), &fp("a")),
Action::Delete {
name: p.name.clone(),
fence: p.fence.clone()
}
);
}
/// The anti-livelock rule: identical desired intent means *never* delete,
/// however long the pod has been pending. Age is not an input to this
/// function at all, which is the strongest way to say so.
#[test]
fn recoverable_with_matching_intent_only_observes() {
let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
assert_eq!(
classify(Some(&p), &fp("same")),
Action::Observe {
name: p.name.clone()
}
);
}
/// Repeated identical Starts can never delete anything — the same
/// classification, arbitrarily many times.
#[test]
fn repeated_identical_starts_never_delete() {
let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
for _ in 0..100 {
assert!(!matches!(
classify(Some(&p), &fp("same")),
Action::Delete { .. }
));
}
}
/// The wedge escape: the user corrected a resource request or image, so
/// the never-started pod is built from configuration they have since
/// changed. Without this row the edit could never materialize.
#[test]
fn recoverable_with_divergent_intent_is_replaced() {
let p = pod(Startup::NeverStartedRecoverable, Some(fp("old")));
assert_eq!(
classify(Some(&p), &fp("new")),
Action::Delete {
name: p.name.clone(),
fence: p.fence.clone()
}
);
}
/// A pod predating the annotation has no recorded intent — that is
/// absence, which the spec groups with divergence.
#[test]
fn missing_recorded_intent_counts_as_divergence() {
let p = pod(Startup::NeverStartedRecoverable, None);
assert_eq!(
classify(Some(&p), &fp("any")),
Action::Delete {
name: p.name.clone(),
fence: p.fence.clone()
}
);
}
/// Permanent-looking pull failures report immediately instead of burning
/// 600s — and, critically, never delete.
#[test]
fn pull_failures_report_and_never_delete() {
for failure in [
PullFailure::Unauthorized,
PullFailure::ManifestUnknown,
PullFailure::ArchMismatch,
] {
let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a")));
// Divergent intent too — still no delete from this arm.
for desired in [fp("a"), fp("different")] {
assert_eq!(
classify(Some(&p), &desired),
Action::Report {
name: p.name.clone(),
failure
}
);
}
}
}
/// Every delete carries the fence from the observation that authorized
/// it. Exhaustive over the delete-producing states, so a future arm that
/// forgets is caught here rather than in a cluster.
#[test]
fn every_delete_carries_the_authorizing_fence() {
let states = [
(Startup::Terminated, fp("a")),
(Startup::NeverStartedProvablyBroken, fp("a")),
(Startup::NeverStartedRecoverable, fp("divergent")),
];
for (startup, desired) in states {
let p = pod(startup, Some(fp("a")));
match classify(Some(&p), &desired) {
Action::Delete { fence, .. } => assert_eq!(fence, p.fence),
other => panic!("expected Delete, got {other:?}"),
}
}
}
}
@@ -0,0 +1,182 @@
//! Cluster auth and client construction (spec §Cluster auth,
//! `docs/remote-agents.md:985-995`).
//!
//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`).
//! `provider_config` carries `context` and `namespace` only — credentials
//! never transit config (I2, `:196-198`).
use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig};
use kube::{Client, Config};
use std::path::{Path, PathBuf};
/// Directories prepended to `PATH` before the client is built.
///
/// Kubeconfigs at Block near-universally authenticate through `exec`
/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that
/// resolve via `PATH` — and this provider inherits a Finder-launched
/// desktop's minimal `PATH`, which contains none of the places those plugins
/// install to (`:989-994`).
const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"];
/// Compute the new `PATH` value: plugin directories first, inherited entries
/// after, in order. Pure so the ordering can be tested without mutating the
/// process's environment.
fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option<std::ffi::OsString> {
let mut dirs: Vec<PathBuf> = PATH_PREPEND.iter().map(PathBuf::from).collect();
if let Some(home) = home {
dirs.push(home.join(".local/bin"));
}
// An empty inherited PATH splits into one empty entry, which POSIX
// resolves as the current directory — a place a credential plugin should
// never be looked up. Drop empties rather than propagate them.
dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty()));
std::env::join_paths(dirs).ok()
}
/// Prepend the plugin directories to this process's `PATH`.
///
/// Modifies the provider's own environment, which is sound here: one process
/// per operation, called before any client or task exists, and the child
/// processes that read it are exactly the credential plugins this exists for.
fn prepend_plugin_path() {
let home = std::env::var_os("HOME");
let existing = std::env::var_os("PATH").unwrap_or_default();
if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) {
std::env::set_var("PATH", joined);
}
}
/// Is `command` runnable — an executable on `PATH`, or an existing path?
fn resolves_on_path(command: &str) -> bool {
if command.contains(std::path::MAIN_SEPARATOR) {
return Path::new(command).is_file();
}
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file()))
.unwrap_or(false)
}
/// The exec plugin the selected context authenticates with, if any.
///
/// Read from the kubeconfig directly rather than from `Config`, which does not
/// expose it. A read failure yields `None`: this lookup exists only to improve
/// an error message, and must never be the thing that fails a deploy.
fn exec_plugin_for(context: Option<&str>) -> Option<ExecConfig> {
let kubeconfig = Kubeconfig::read().ok()?;
let context_name = context
.map(str::to_string)
.or_else(|| kubeconfig.current_context.clone())?;
let user_name = kubeconfig
.contexts
.iter()
.find(|c| c.name == context_name)
.and_then(|c| c.context.as_ref())
.and_then(|c| c.user.clone())?;
kubeconfig
.auth_infos
.iter()
.find(|a| a.name == user_name)
.and_then(|a| a.auth_info.as_ref())
.and_then(|a| a.exec.clone())
}
/// Turn a client-construction failure into an error a user can act on.
///
/// When the context authenticates through an exec plugin that is not on
/// `PATH`, that is almost always the cause, and the actionable fact is the
/// plugin's name — not a kube-rs error chain (`:994-995`).
fn explain(context: Option<&str>, error: &kube::Error) -> String {
if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) {
if !resolves_on_path(&command) {
return format!(
"kubeconfig context {} authenticates with the credential plugin \
{command:?}, which is not on PATH. Install it or add its \
directory to PATH, then try again.",
context.unwrap_or("(current)")
);
}
}
format!(
"could not connect to the cluster using kubeconfig context {}: {error}",
context.unwrap_or("(current)")
)
}
/// Build a client for the selected context.
pub async fn connect(context: Option<&str>) -> Result<Client, String> {
prepend_plugin_path();
let options = KubeConfigOptions {
context: context.map(str::to_string),
..Default::default()
};
let config = Config::from_kubeconfig(&options).await.map_err(|e| {
// A named context that does not exist is a user typo, and the
// kube-rs message for it is already specific.
format!(
"could not load kubeconfig for context {}: {e}",
context.unwrap_or("(current)")
)
})?;
Client::try_from(config).map_err(|e| explain(context, &e))
}
#[cfg(test)]
mod tests {
use super::*;
/// The three plugin directories must end up ahead of the inherited PATH,
/// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`.
/// Tested on the pure computation: mutating the process PATH here would
/// race every other test in the binary.
#[test]
fn plugin_directories_are_prepended_in_order() {
let joined = prepended_path(
Some(Path::new("/tmp/fake-home")),
std::ffi::OsStr::new("/inherited/bin:/usr/bin"),
)
.unwrap();
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(
dirs,
[
"/opt/homebrew/bin",
"/usr/local/bin",
"/tmp/fake-home/.local/bin",
"/inherited/bin",
"/usr/bin",
]
.map(PathBuf::from)
);
}
/// No `HOME` is not a failure — the two absolute directories still apply.
#[test]
fn missing_home_still_prepends_the_absolute_directories() {
let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap();
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(
dirs,
["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from)
);
}
/// An empty inherited PATH must not produce an empty entry, which the
/// shell and `resolves_on_path` would both read as the cwd.
#[test]
fn empty_inherited_path_yields_no_empty_entry() {
let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap();
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(
dirs,
["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)
);
}
#[test]
fn resolves_absolute_paths_directly() {
assert!(resolves_on_path("/bin/sh"));
assert!(!resolves_on_path("/nonexistent/plugin-binary"));
}
}
@@ -0,0 +1,427 @@
//! The real [`Substrate`]: kube-rs against a live apiserver.
//!
//! Everything that *decides* lives in `classify`/`gc`; this module only
//! performs I/O and maps apiserver responses onto the trait's vocabulary.
//! Three mappings here are normative rather than incidental:
//!
//! * **409 is discriminated on `Status.reason`, never on the code.** A create
//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is
//! `Conflict`. Branching on `code == 409` conflates a lost create race with
//! a stale fence and is the trap the spec names (`:780-794`).
//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"`
//! is the cache read, and a confirmed absence from a cache is proof of
//! nothing (`:761-769`).
//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s
//! budget applies. Passing `0` is a force-kill that discards the shutdown
//! window the pod declares (`:1185-1189`).
use crate::classify::Fence;
use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate};
use chrono::{DateTime, Utc};
use k8s_openapi::api::core::v1::{Namespace, Pod, Secret};
use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions};
use kube::core::ErrorResponse;
use kube::{Client, Resource};
use std::time::{Duration, Instant};
/// `Status.reason` values we branch on. Spelled once so the two 409 arms are
/// visibly the same discriminator read two ways.
///
/// These are wire strings `apimachinery` chooses, not names this crate picks:
/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`,
/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`.
/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no
/// upstream constant to bind to and the spelling is pinned by test instead.
const REASON_ALREADY_EXISTS: &str = "AlreadyExists";
const REASON_CONFLICT: &str = "Conflict";
const REASON_NOT_FOUND: &str = "NotFound";
const REASON_FORBIDDEN: &str = "Forbidden";
/// The apiserver-backed substrate for one deploy operation.
pub struct Cluster {
client: Client,
namespace: String,
/// Start of *this operation*, for the deadline. Monotonic: the 600s budget
/// must not move when the wall clock does.
started: Instant,
}
/// The typed API error underneath a `kube::Error`, if it is one.
fn api_error(error: &kube::Error) -> Option<&ErrorResponse> {
match error {
kube::Error::Api(response) => Some(response),
_ => None,
}
}
/// Does this error carry the given `Status.reason`?
fn reason_is(error: &kube::Error, reason: &str) -> bool {
api_error(error).is_some_and(|e| e.reason == reason)
}
impl Cluster {
pub fn new(client: Client, namespace: &str) -> Self {
Self {
client,
namespace: namespace.to_string(),
started: Instant::now(),
}
}
fn pods(&self) -> Api<Pod> {
Api::namespaced(self.client.clone(), &self.namespace)
}
fn secrets(&self) -> Api<Secret> {
Api::namespaced(self.client.clone(), &self.namespace)
}
/// List an object kind through the raw client so the response's HTTP
/// `Date` header is reachable.
///
/// `Api::list` returns only the decoded body, and the apiserver's clock is
/// the *only* clock the orphan-Secret age gate may use — a desktop's local
/// clock running fast computes every in-flight Secret as expired
/// (`:1321-1335`). So the list goes through `Client::send`, which hands
/// back the whole `http::Response`.
async fn list_with_date<K>(
&self,
selector: &str,
) -> Result<(Vec<K>, Option<DateTime<Utc>>), String>
where
K: Resource<Scope = k8s_openapi::NamespaceResourceScope>
+ Clone
+ serde::de::DeserializeOwned
+ std::fmt::Debug,
K::DynamicType: Default,
{
let dt = K::DynamicType::default();
let url = K::url_path(&dt, Some(&self.namespace));
// resourceVersion deliberately unset: quorum read.
let params = ListParams {
label_selector: Some(selector.to_string()),
..Default::default()
};
let request = kube::core::Request::new(url)
.list(&params)
.map_err(|e| format!("could not build a list request: {e}"))?;
let (parts, body) = request.into_parts();
let response = self
.client
.send(http::Request::from_parts(parts, body.into()))
.await
.map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?;
// Parsed before the body is consumed, and independently of it: a
// missing or malformed header is not a list failure, it just means the
// orphan sweep has no clock and skips.
let server_now = response
.headers()
.get(http::header::DATE)
.and_then(|v| v.to_str().ok())
.and_then(|v| DateTime::parse_from_rfc2822(v).ok())
.map(|v| v.with_timezone(&Utc));
let bytes = http_body_util::BodyExt::collect(response.into_body())
.await
.map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))?
.to_bytes();
let list: kube::core::ObjectList<K> = serde_json::from_slice(&bytes)
.map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?;
Ok((list.items, server_now))
}
}
impl Substrate for Cluster {
async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> {
let api: Api<Namespace> = Api::all(self.client.clone());
if api
.get_opt(namespace)
.await
.map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))?
.is_some()
{
return Ok(());
}
let spec = Namespace {
metadata: kube::core::ObjectMeta {
name: Some(namespace.to_string()),
..Default::default()
},
..Default::default()
};
match api.create(&PostParams::default(), &spec).await {
Ok(_) => Ok(()),
// Someone else created it between our check and our create. That
// is the desired end state, not a failure.
Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()),
// Namespace-create is frequently denied on shared clusters. Name
// the exact command an operator runs, and never silently fall back
// to `default` — deploying an agent into someone else's namespace
// is worse than refusing (`:1002-1005`).
Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!(
"not authorized to create namespace {namespace}. Ask a cluster \
administrator to run `kubectl create namespace {namespace}`, \
then try again."
)),
Err(e) => Err(format!("could not create namespace {namespace}: {e}")),
}
}
async fn list_pods(&self, selector: &str) -> Result<(Vec<Pod>, Option<DateTime<Utc>>), String> {
self.list_with_date::<Pod>(selector).await
}
async fn list_secrets(&self, selector: &str) -> Result<Vec<Secret>, String> {
Ok(self.list_with_date::<Secret>(selector).await?.0)
}
async fn secret_exists(&self, name: &str) -> Result<bool, String> {
// `GetParams::default()` leaves resourceVersion unset — the quorum
// read this check requires to be proof of anything.
match self.secrets().get_with(name, &GetParams::default()).await {
Ok(_) => Ok(true),
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false),
Err(e) => Err(format!("could not check whether secret {name} exists: {e}")),
}
}
async fn create_secret(&self, secret: &Secret) -> Result<(), String> {
let name = secret.metadata.name.clone().unwrap_or_default();
self.secrets()
.create(&PostParams::default(), secret)
.await
.map(|_| ())
.map_err(|e| format!("could not create secret {name}: {e}"))
}
async fn create_pod(&self, pod: &Pod) -> Result<CreateOutcome, String> {
let name = pod.metadata.name.clone().unwrap_or_default();
match self.pods().create(&PostParams::default(), pod).await {
Ok(_) => Ok(CreateOutcome::Created),
// The deterministic name is taken: a concurrent attempt won the
// election. Discriminated on the reason — a 409 whose reason is
// `Conflict` is a different condition and must not be read as a
// lost race.
Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists),
Err(e) => Err(format!("could not create pod {name}: {e}")),
}
}
async fn delete_pod(&self, name: &str, fence: &Fence) -> Result<DeleteOutcome, String> {
let params = DeleteParams {
preconditions: Some(Preconditions {
uid: Some(fence.uid.clone()),
resource_version: Some(fence.resource_version.clone()),
}),
// grace_period_seconds deliberately unset: the pod's own 60s
// budget applies.
..Default::default()
};
match self.pods().delete(name, &params).await {
Ok(_) => Ok(DeleteOutcome::Accepted),
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound),
// The object changed since the observation that authorized this
// delete. Same HTTP code as the create race above, different
// reason, different meaning.
Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed),
Err(e) => Err(format!("could not delete pod {name}: {e}")),
}
}
async fn delete_secret(&self, name: &str) -> Result<(), String> {
match self.secrets().delete(name, &DeleteParams::default()).await {
Ok(_) => Ok(()),
// Already gone is the desired end state.
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()),
Err(e) => Err(format!("could not delete secret {name}: {e}")),
}
}
async fn get_pod(&self, name: &str) -> Result<Option<Pod>, String> {
match self.pods().get_with(name, &GetParams::default()).await {
Ok(pod) => Ok(Some(pod)),
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None),
Err(e) => Err(format!("could not read pod {name}: {e}")),
}
}
async fn sleep(&self, duration: Duration) {
tokio::time::sleep(duration).await;
}
fn elapsed(&self) -> Duration {
self.started.elapsed()
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::Response;
use kube::client::Body;
use std::sync::{Arc, Mutex};
use tower::service_fn;
fn list_response(date: Option<&str>) -> Response<Body> {
let mut response = Response::builder().status(200);
if let Some(date) = date {
response = response.header(http::header::DATE, date);
}
response
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"apiVersion": "v1",
"kind": "PodList",
"metadata": {"resourceVersion": "17"},
"items": [{
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": "sprig"},
"spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
}]
}))
.unwrap(),
))
.unwrap()
}
async fn list_through_real_request_path(
date: Option<&'static str>,
) -> (Vec<Pod>, Option<DateTime<Utc>>, String) {
let observed_uri = Arc::new(Mutex::new(None));
let service_uri = Arc::clone(&observed_uri);
let service = service_fn(move |request: http::Request<Body>| {
let service_uri = Arc::clone(&service_uri);
async move {
*service_uri.lock().unwrap() = Some(request.uri().to_string());
Ok::<_, std::convert::Infallible>(list_response(date))
}
});
let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns");
let result = cluster
.list_with_date::<Pod>("app.kubernetes.io/managed-by=buzz-backend-kubernetes")
.await
.unwrap();
let uri = observed_uri.lock().unwrap().take().unwrap();
(result.0, result.1, uri)
}
/// Exercise the shipped `Request` + `Client::send` seam. A fake
/// reconciler would not prove that kube-rs emits a quorum list request or
/// that the apiserver's clock survives body decoding.
#[tokio::test]
async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() {
let (pods, server_now, uri) =
list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await;
assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig"));
assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?"));
assert!(
uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes")
);
assert!(
!uri.contains("resourceVersion"),
"cache read leaked into {uri}"
);
assert_eq!(
server_now.unwrap().to_rfc3339(),
"2026-08-02T04:00:00+00:00"
);
}
/// Header failure is deliberately not list failure: without a trustworthy
/// apiserver clock the orphan sweep skips, but normal reconciliation still
/// receives the decoded objects.
#[tokio::test]
async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() {
for date in [Some("not a date"), None] {
let (pods, server_now, _) = list_through_real_request_path(date).await;
assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
assert!(server_now.is_none(), "unexpected clock for {date:?}");
}
}
/// A typed apiserver error, as kube-rs surfaces it.
fn api(reason: &str, code: u16) -> kube::Error {
kube::Error::Api(ErrorResponse {
status: "Failure".into(),
message: String::new(),
reason: reason.into(),
code,
})
}
/// The one discriminator the whole file rests on, and the trap the spec
/// predicts: "an implementation that branches on the code alone will
/// eventually take the adoption path on a failed delete or vice versa"
/// (`:788-790`).
///
/// Both of these are 409. Reading the *code* makes them identical; reading
/// `Status.reason` keeps a lost create race and a stale fence apart. The
/// mutation that must fail this test is `e.reason == …` → `e.code == 409`,
/// which no other test in the crate would catch — the fakes never produce
/// a real `kube::Error`.
#[test]
fn the_two_409s_are_never_conflated() {
let already_exists = api(REASON_ALREADY_EXISTS, 409);
let conflict = api(REASON_CONFLICT, 409);
assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS));
assert!(reason_is(&conflict, REASON_CONFLICT));
// The cross terms are the whole point.
assert!(!reason_is(&already_exists, REASON_CONFLICT));
assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS));
}
/// A transport-level failure is not an apiserver verdict. It must fall
/// through to the error arm rather than being read as any reason — a
/// connection reset silently classified as `NotFound` would report a pod
/// as confirmed-absent, which the classifier treats as proof.
#[test]
fn a_non_api_error_carries_no_reason() {
let transport = kube::Error::LinesCodecMaxLineLengthExceeded;
assert!(api_error(&transport).is_none());
for reason in [
REASON_ALREADY_EXISTS,
REASON_CONFLICT,
REASON_NOT_FOUND,
REASON_FORBIDDEN,
] {
assert!(!reason_is(&transport, reason), "matched {reason}");
}
}
/// `reason` is `#[serde(default)]` in kube-core, so an apiserver that
/// omits it yields an empty string. That must match nothing rather than
/// matching an empty pattern by accident.
#[test]
fn an_absent_reason_matches_nothing() {
let bare = api("", 409);
assert!(!reason_is(&bare, REASON_ALREADY_EXISTS));
assert!(!reason_is(&bare, REASON_CONFLICT));
}
/// The consts are the apiserver's spelling, asserted against literals
/// rather than against themselves.
///
/// Every other test here references the consts symbolically on both sides
/// — fixture *and* assertion — which is true for any pair of distinct
/// values. That tests the discriminator is self-consistent, not that it is
/// correct: swapping the two 409 values inverts `AlreadyExists` and
/// `Conflict` at a real apiserver (`:788-790`'s failure, reached by
/// editing a string instead of a branch) with every other test still
/// green. These are `apimachinery`'s wire strings and kube-core exposes no
/// constant for them, so a literal is the only external anchor available.
/// Found by Quinn's mutation matrix; M2/M3/M4 survived without it.
#[test]
fn the_reason_consts_are_the_apiservers_spelling() {
assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists");
assert_eq!(REASON_CONFLICT, "Conflict");
assert_eq!(REASON_NOT_FOUND, "NotFound");
assert_eq!(REASON_FORBIDDEN, "Forbidden");
}
}
@@ -0,0 +1,470 @@
//! `provider_config` parsing and the `info` config schema
//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`).
//!
//! Nine fields, all optional except `image` (required at parse time; the
//! schema offers the published sprig image as a prefill default — §Image).
//! No credential field exists, by I2: cluster auth comes from ambient
//! kubeconfig resolution and nothing else (`:196-198`).
use crate::image::{self, ImageRef};
/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four
/// configurable — `cargo build` in an agent workspace makes 500m/1Gi
/// unrealistic).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resources {
pub cpu_request: String,
pub memory_request: String,
pub cpu_limit: String,
pub memory_limit: String,
}
impl Default for Resources {
fn default() -> Self {
Self {
cpu_request: "1".into(),
memory_request: "2Gi".into(),
cpu_limit: "2".into(),
memory_limit: "4Gi".into(),
}
}
}
/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and
/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two.
pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200;
/// Default `image` schema prefill: the published sprig image, in tag+digest
/// form so the tag stays human-traceable to its git SHA while the digest does
/// the pinning (§Image — tag-only refs are rejected; `image::parse` drops the
/// tag on normalization). This is a UI prefill, not a baked fallback: `image`
/// stays required, an empty value still fails closed, and the value always
/// arrives explicitly in `provider_config`, so create-intent fingerprints are
/// unaffected by provider upgrades.
pub const DEFAULT_IMAGE: &str = "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76";
/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening).
pub const RUN_AS_UID: i64 = 10001;
pub const RUN_AS_GID: i64 = 10001;
/// Writable workspace root; also `HOME` and the harness's cwd
/// (§Working directory).
pub const WORKSPACE_PATH: &str = "/home/agent";
/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum
/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain.
pub const TERMINATION_GRACE_SECONDS: i64 = 60;
/// The only restart policy v1 ships. `OnFailure` is double-gated on the
/// harness exit-code contract *and* a crash-loop classification row the state
/// machine does not have (`:1121-1139`); until both land the provider refuses
/// the combination rather than shipping against an undefended convention.
pub const RESTART_POLICY: &str = "Never";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderConfig {
/// kubeconfig context; `None` uses the current context.
pub context: Option<String>,
pub namespace: String,
pub image: ImageRef,
pub resources: Resources,
/// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`].
pub inactivity_seconds: Option<u64>,
pub service_account: Option<String>,
}
/// Read an optional non-empty string field. Rejects non-string scalars rather
/// than stringifying them, so a mistyped field is named at the boundary.
fn optional_string(cfg: &serde_json::Value, field: &str) -> Result<Option<String>, String> {
match cfg.get(field) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())),
Some(other) => Err(format!(
"provider_config.{field} must be a string, got {other}"
)),
}
}
/// Read an optional unsigned integer. The desktop's form omits blank numeric
/// fields rather than sending `""`, but a hand-crafted payload may send a
/// numeric string — accept both, refuse anything else.
fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result<Option<u64>, String> {
match cfg.get(field) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
format!("provider_config.{field} must be a non-negative integer, got {n}")
}),
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
Some(serde_json::Value::String(s)) => s.trim().parse::<u64>().map(Some).map_err(|_| {
format!("provider_config.{field} must be a non-negative integer, got {s:?}")
}),
Some(other) => Err(format!(
"provider_config.{field} must be a non-negative integer, got {other}"
)),
}
}
/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a
/// typo fails with a named field instead of an apiserver rejection partway
/// through a deploy.
fn valid_namespace(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 63
&& name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn parse(cfg: &serde_json::Value) -> Result<ProviderConfig, String> {
if !cfg.is_object() && !cfg.is_null() {
return Err("provider_config must be a JSON object".to_string());
}
let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| {
"provider_config.namespace is required: the info schema supplies a \
generated default, so an empty value means the form was cleared"
.to_string()
})?;
if !valid_namespace(&namespace) {
return Err(format!(
"provider_config.namespace {namespace:?} is not a valid Kubernetes \
namespace (lowercase alphanumerics and '-', ≤63 characters)"
));
}
let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?;
let defaults = Resources::default();
let resources = Resources {
cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request),
memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request),
cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit),
memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit),
};
// `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop)
// meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which
// §Pod shape forbids until the harness exit-code contract is pinned AND the
// state machine gains a crash-loop row. Refusing the *combination* is what
// the spec asks for; silently downgrading to `Never` would ship an
// indefinite agent that dies on its first crash.
let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? {
None => Some(DEFAULT_INACTIVITY_SECONDS),
Some(0) => {
return Err(
"provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \
supported in this version: it requires restartPolicy OnFailure, \
which is gated on the harness exit-code contract. Set a positive \
number of seconds."
.to_string(),
)
}
Some(n) => Some(n),
};
Ok(ProviderConfig {
context: optional_string(cfg, "context")?,
namespace,
image,
resources,
inactivity_seconds,
service_account: optional_string(cfg, "service_account")?,
})
}
/// A fresh `buzz-agents-<rand6>` namespace default.
///
/// Computed per `info` call, which is how "random default" is satisfied with
/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace).
pub fn generated_namespace() -> String {
use rand::RngExt;
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::rng();
let suffix: String = (0..6)
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
.collect();
format!("buzz-agents-{suffix}")
}
/// The `config_schema` returned by `info`. Drives the UI form:
/// `properties[*].default` prefill, scalar coercion, `required` gating
/// (`:407-411`).
pub fn config_schema() -> serde_json::Value {
let defaults = Resources::default();
serde_json::json!({
"type": "object",
"properties": {
"context": {
"type": "string",
"title": "Kubeconfig context",
"description": "Context from your kubeconfig. Leave empty to use the current context."
},
"namespace": {
"type": "string",
"title": "Namespace",
"description": "Created if it does not exist.",
"default": generated_namespace()
},
"image": {
"type": "string",
"title": "Agent image",
"description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:<digest>. Tags alone are not accepted: this pod holds the agent's private key.",
"default": DEFAULT_IMAGE
},
"cpu_request": {
"type": "string", "title": "CPU request", "default": defaults.cpu_request
},
"memory_request": {
"type": "string", "title": "Memory request", "default": defaults.memory_request
},
"cpu_limit": {
"type": "string", "title": "CPU limit", "default": defaults.cpu_limit
},
"memory_limit": {
"type": "string", "title": "Memory limit", "default": defaults.memory_limit
},
"inactivity_seconds": {
"type": "number",
"title": "Stop after inactivity (seconds)",
"description": "The agent exits after this long with no work, and can be started again at any time.",
"default": DEFAULT_INACTIVITY_SECONDS
},
"service_account": {
"type": "string",
"title": "Service account",
"description": "Scheduling/RBAC identity only. No API token is mounted."
}
},
"required": ["namespace", "image"]
})
}
#[cfg(test)]
mod tests {
use super::*;
fn digest_ref() -> String {
format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64))
}
fn minimal() -> serde_json::Value {
serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()})
}
#[test]
fn applies_spec_defaults() {
let c = parse(&minimal()).unwrap();
assert_eq!(c.resources, Resources::default());
assert_eq!(c.resources.cpu_request, "1");
assert_eq!(c.resources.memory_request, "2Gi");
assert_eq!(c.resources.cpu_limit, "2");
assert_eq!(c.resources.memory_limit, "4Gi");
assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS));
assert_eq!(c.context, None);
assert_eq!(c.service_account, None);
}
#[test]
fn all_four_resources_are_configurable() {
let mut cfg = minimal();
cfg["cpu_request"] = "500m".into();
cfg["memory_request"] = "1Gi".into();
cfg["cpu_limit"] = "4".into();
cfg["memory_limit"] = "8Gi".into();
let c = parse(&cfg).unwrap();
assert_eq!(
c.resources,
Resources {
cpu_request: "500m".into(),
memory_request: "1Gi".into(),
cpu_limit: "4".into(),
memory_limit: "8Gi".into(),
}
);
}
/// The desktop's form omits blank numeric fields; a hand-crafted payload
/// may send a numeric string. Both must mean the same thing.
#[test]
fn inactivity_accepts_number_string_and_omission() {
let mut cfg = minimal();
cfg["inactivity_seconds"] = serde_json::json!(300);
assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
cfg["inactivity_seconds"] = serde_json::json!("300");
assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
cfg["inactivity_seconds"] = serde_json::json!("");
assert_eq!(
parse(&cfg).unwrap().inactivity_seconds,
Some(DEFAULT_INACTIVITY_SECONDS)
);
}
/// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather
/// than silently downgrade — a downgraded agent dies on its first crash
/// while the user believes they asked for indefinite.
#[test]
fn refuses_indefinite_lifetime() {
let mut cfg = minimal();
cfg["inactivity_seconds"] = serde_json::json!(0);
let err = parse(&cfg).unwrap_err();
assert!(err.contains("inactivity_seconds"), "got: {err}");
assert!(
err.contains("OnFailure"),
"error should name the gate: {err}"
);
}
#[test]
fn rejects_negative_and_non_numeric_inactivity() {
for bad in [
serde_json::json!(-1),
serde_json::json!(1.5),
serde_json::json!("soon"),
serde_json::json!(true),
] {
let mut cfg = minimal();
cfg["inactivity_seconds"] = bad.clone();
assert!(parse(&cfg).is_err(), "accepted {bad}");
}
}
#[test]
fn image_is_required_and_must_be_digest_pinned() {
let mut cfg = minimal();
cfg.as_object_mut().unwrap().remove("image");
assert!(parse(&cfg).unwrap_err().contains("provider_config.image"));
cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into();
assert!(parse(&cfg).unwrap_err().contains("digest-pinned"));
}
#[test]
fn rejects_invalid_namespace_names() {
for bad in [
"",
"Buzz-Agents",
"-leading",
"trailing-",
"has_underscore",
&"n".repeat(64),
] {
let mut cfg = minimal();
cfg["namespace"] = bad.into();
assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}");
}
}
/// I2 corollary: there is no config path for cluster credentials, so a
/// caller that tries to supply one gets no effect from it. Asserting the
/// parsed struct has no such field is the closest a test can get to
/// "the type makes it impossible".
#[test]
fn credential_fields_have_no_effect() {
let mut cfg = minimal();
cfg["token"] = "hunter2".into();
cfg["client_key"] = "hunter2".into();
let c = parse(&cfg).unwrap();
let rendered = format!("{c:?}");
assert!(
!rendered.contains("hunter2"),
"config absorbed a credential: {rendered}"
);
}
#[test]
fn mistyped_string_fields_are_named() {
let mut cfg = minimal();
cfg["namespace"] = serde_json::json!(42);
assert!(parse(&cfg)
.unwrap_err()
.contains("provider_config.namespace"));
}
#[test]
fn generated_namespaces_are_fresh_and_valid() {
let a = generated_namespace();
let b = generated_namespace();
assert_ne!(a, b, "namespace default is not random");
assert!(valid_namespace(&a), "{a} is not a valid namespace");
assert!(a.starts_with("buzz-agents-"));
assert_eq!(a.len(), "buzz-agents-".len() + 6);
}
/// The schema's own namespace default must be a value the parser accepts —
/// otherwise the UI prefills a form that fails on submit.
#[test]
fn schema_default_namespace_round_trips_through_parse() {
let schema = config_schema();
let default = schema["properties"]["namespace"]["default"]
.as_str()
.unwrap();
let cfg = serde_json::json!({"namespace": default, "image": digest_ref()});
assert_eq!(parse(&cfg).unwrap().namespace, default);
}
/// Same guarantee for the image prefill: the schema's default must be a
/// value `image::parse` accepts, or the UI prefills a form that fails on
/// submit. Its tag+digest form normalizes to the tagless canonical form.
#[test]
fn schema_default_image_round_trips_through_parse() {
let schema = config_schema();
let default = schema["properties"]["image"]["default"].as_str().unwrap();
assert_eq!(default, DEFAULT_IMAGE);
let cfg = serde_json::json!({"namespace": "buzz-agents-abc123", "image": default});
let parsed = parse(&cfg).unwrap();
assert_eq!(
parsed.image.as_str(),
"ghcr.io/block/buzz-sprig@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76"
);
}
/// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the
/// count is pinned so a field added without a spec change is caught here.
#[test]
fn schema_declares_exactly_the_nine_v1_fields() {
let schema = config_schema();
let props = schema["properties"].as_object().unwrap();
let mut keys: Vec<&str> = props.keys().map(String::as_str).collect();
keys.sort();
assert_eq!(
keys,
[
"context",
"cpu_limit",
"cpu_request",
"image",
"inactivity_seconds",
"memory_limit",
"memory_request",
"namespace",
"service_account"
]
);
assert_eq!(
schema["required"],
serde_json::json!(["namespace", "image"])
);
}
/// I2's key lint rejects any field whose word-split contains
/// secret|password|token|key|credential. A schema field tripping it would
/// make every deploy fail validation desktop-side (`:185-198`).
#[test]
fn no_schema_field_trips_the_i2_key_lint() {
const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"];
let schema = config_schema();
for field in schema["properties"].as_object().unwrap().keys() {
for word in field.split(['_', '-']) {
assert!(
!BANNED.contains(&word),
"field {field:?} contains I2-banned word {word:?}"
);
}
}
}
}
+732
View File
@@ -0,0 +1,732 @@
//! Building the pod environment (spec §Launch data, §Entrypoint mapping table).
//!
//! The three tiers are resolved *here*, before serialization, because a
//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if
//! two tiers supplied the same key, whichever entry landed in the map would
//! win silently. Resolving in-provider makes later-wins explicit and testable.
use crate::wire::{AgentPayload, LaunchBlock};
use std::collections::BTreeMap;
/// Keys the authoritative tier owns.
///
/// Load-bearing, not documentation: tier 3 *clears* every key on this list
/// before writing its own values, so a key the authoritative tier has no value
/// for is **removed** rather than left holding a lower-tier value. Plain
/// overwrite is not enough — most of these are written conditionally
/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty,
/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier
/// could supply the value for exactly the cases the authoritative tier stays
/// silent on. Clearing is also what the local spawn does: the desktop strips
/// reserved keys from user env before the authoritative layer is written
/// (`env_vars.rs:54-57`), so absent-means-absent in both paths.
const AUTHORITATIVE_KEYS: &[&str] = &[
"BUZZ_RELAY_URL",
"BUZZ_PRIVATE_KEY",
"NOSTR_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_ACP_AGENT_OWNER",
"BUZZ_ACP_AGENT_COMMAND",
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
"BUZZ_ACP_MCP_COMMAND",
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
START_NONCE_KEY,
];
/// The attempt's generation, as the harness sees it. Also the Secret's name
/// suffix — one generation, one identity — so the reconciler restamps this on
/// every create attempt rather than letting the caller's value persist across
/// a retry.
pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE";
/// Presence is the only remote liveness signal (I3), so a launch that
/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key
/// collision, there is no "authoritative value" to overwrite it with. Refuse.
const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE";
/// Kubernetes' own cap on the summed value bytes of a Secret
/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized
/// env surfaces as a named provider error rather than an apiserver rejection
/// partway through a deploy.
const MAX_SECRET_BYTES: usize = 1024 * 1024;
/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`.
///
/// Kubernetes validates Secret *keys* as `IsConfigMapKey`
/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key.
/// What the kubelet then does with such a key **changed between versions**:
/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an
/// `InvalidEnvironmentVariableNames` warning event
/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter
/// is gone (KEP-4369) and the key is injected verbatim. The same manifest
/// would silently drop a variable on one cluster and set it on another, so we
/// fail closed on the provider side and get one deterministic behavior.
fn is_posix_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
/// An identity component (L1 item 1) is present only if it is nonempty after
/// trimming — and the **trimmed form is what gets stored**. The validator and
/// the writer must never disagree about the value: a guard that accepts
/// `" wss://relay "` and then writes it with the padding intact has only
/// moved the failure from a loud refusal to a connect error in the harness.
fn identity_component(value: &str) -> Option<&str> {
let trimmed = value.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
/// The harness's `allowlist` gate mode, spelled as the desktop serializes
/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it.
const RESPOND_TO_ALLOWLIST: &str = "allowlist";
/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses
/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`).
///
/// Deliberately the **harness's** four and not the desktop's three: the desktop
/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the
/// harness starts fine with it. This guard exists to cover non-desktop callers,
/// so inheriting a desktop-only narrowing would refuse a launch that works.
const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"];
/// Refuse a respond-to gate the harness will reject at config parse.
///
/// The local spawn path re-validates this before spawning — "doing it here
/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy
/// path projects the record's fields straight through. Without this, a gate
/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy:
/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle
/// leaves a Secret the in-call path never reaps (only a later deploy's orphan
/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is
/// "startup not confirmed", indistinguishable from a slow cluster.
///
/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`),
/// deliberately 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 —
/// and a stale list is already harmless here, since
/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears.
fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> {
// Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at
// the harness — a parse failure even earlier than the config errors below.
if !RESPOND_TO_MODES.contains(&respond_to) {
return Err(format!(
"deploy refused: respond_to {respond_to:?} is not a mode the \
harness accepts (expected one of {}) — the pod would fail to \
parse its arguments, be replaced, and leave a Secret behind on \
every attempt",
RESPOND_TO_MODES.join(", ")
));
}
if respond_to != RESPOND_TO_ALLOWLIST {
return Ok(());
}
let entries = allowlist.unwrap_or_default();
if entries.is_empty() {
return Err(format!(
"deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \
allowlist is empty — the harness refuses this at startup, so the \
pod would fail, be replaced, and leave a Secret behind on every \
attempt"
));
}
for entry in entries {
let trimmed = entry.trim();
if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"deploy refused: invalid pubkey in respond_to_allowlist: \
{entry:?} (must be exactly 64 hex characters)"
));
}
}
Ok(())
}
/// Inputs the provider itself supplies to the authoritative tier.
pub struct AuthoritativeInputs<'a> {
/// The attempt's generation token — also the Secret's name suffix, so the
/// lifecycle correlator and the Secret generation are one identity.
pub generation: &'a str,
/// Resolved from `provider_config.inactivity_seconds`; `None` when the
/// indefinite opt-in was chosen (which this version refuses elsewhere).
pub inactivity_seconds: Option<u64>,
}
/// Resolve the full pod environment.
///
/// Order is the spec's, and the function body is deliberately three writes in
/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather
/// than argued.
pub fn build_env(
agent: &AgentPayload,
auth: AuthoritativeInputs<'_>,
) -> Result<BTreeMap<String, String>, String> {
let default_launch = LaunchBlock::default();
let launch = agent.launch.as_ref().unwrap_or(&default_launch);
let mut env: BTreeMap<String, String> = BTreeMap::new();
// Tier 1 — overridable behavior defaults.
env.extend(launch.policy_env.clone());
// Tier 2 — user/layered env. The descriptor already merged
// global < persona < agent, so `agent.env_vars` is NOT re-merged on top
// (§Launch data tier 2) — doing so would resurrect a layer the desktop
// already resolved. When the desktop predates the `launch` block we fall
// back to the legacy field, which is the only case it is the truth.
if agent.launch.is_some() {
env.extend(launch.env.clone());
} else {
env.extend(agent.env_vars.clone());
}
// Validate what the lower tiers contributed, before the authoritative
// tier overwrites any of it. A reserved-key collision is NOT fatal: the
// spec's precedence is later-wins, so tier 3 simply overwrites it, which
// is exactly what a local spawn does. Only a key that has no
// authoritative counterpart to overwrite it — presence suppression — is
// a refusal.
for key in env.keys() {
if !is_posix_env_key(key) {
return Err(format!(
"env key {key:?} is not a POSIX environment variable name \
([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \
inconsistently across cluster versions"
));
}
if key.eq_ignore_ascii_case(FORBIDDEN_KEY) {
return Err(format!(
"{FORBIDDEN_KEY} must not be set on a remote agent: presence \
is the only signal that a remote agent is alive"
));
}
}
// Tier 3 — authoritative. Every key it owns is cleared first, then the
// values it has are written, so it wins at a key whether or not it has a
// value there (see [`AUTHORITATIVE_KEYS`]).
for key in AUTHORITATIVE_KEYS {
env.remove(*key);
}
// Identity comes from top-level payload fields, never from `env_vars`
// (§Reserved-key rule). All three components must be nonempty: an agent
// that cannot reach a relay is the identityless launch L1 item 1 exists to
// prevent, and a blank field would otherwise sail through into the Secret
// and produce a pod that starts, fails to connect, and looks like a
// network problem.
let Some(relay_url) = identity_component(&agent.relay_url) else {
return Err("deploy refused: relay_url is empty — the agent would have \
no relay to connect to"
.to_string());
};
env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string());
env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
// The git credential/signing helpers read NOSTR_PRIVATE_KEY.
env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
// Owner: at least one of these must resolve, or the harness cannot match
// `!shutdown` and §Stop describes a mechanism that does not work.
let auth_tag = agent.auth_tag.as_deref().and_then(identity_component);
let owner = launch.owner_pubkey.as_deref().and_then(identity_component);
match (auth_tag, owner) {
(None, None) => {
return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \
resolved — without an owner the agent cannot honor \
!shutdown"
.to_string())
}
(tag, own) => {
if let Some(t) = tag {
env.insert("BUZZ_AUTH_TAG".into(), t.to_string());
}
if let Some(o) = own {
env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string());
}
}
}
// The harness and MCP binaries are resolved against the *image's* PATH.
// A host path forwarded from the desktop is guaranteed absent in the
// container (§Launch data, host-resolved values).
if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) {
env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string());
}
if !launch.args.is_empty() {
// Comma-joined because that is what the harness's CLI parser decodes,
// and what the desktop's local spawn does. An argument containing a
// comma is unrepresentable in both paths; inventing an escaping
// scheme here would produce args the harness cannot decode.
env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(","));
}
env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into());
if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) {
validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?;
env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string());
}
if let Some(list) = agent
.respond_to_allowlist
.as_ref()
.filter(|l| !l.is_empty())
{
env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(","));
}
if let Some(secs) = auth.inactivity_seconds {
env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string());
}
// The generation token doubles as the lifecycle-frame correlator, so pod
// logs and observer frames share one identity (§K8s Secrets).
env.insert(START_NONCE_KEY.into(), auth.generation.to_string());
let total: usize = env.values().map(String::len).sum();
if total > MAX_SECRET_BYTES {
return Err(format!(
"agent environment is {total} bytes; Kubernetes caps Secret data \
at {MAX_SECRET_BYTES}"
));
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
fn payload_json(extra_agent: serde_json::Value) -> AgentPayload {
let mut agent = serde_json::json!({
"name": "a",
"relay_url": "wss://relay.example",
"private_key_nsec": "nsec1example",
"auth_tag": "tag-1",
});
let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) =
(&mut agent, extra_agent)
else {
panic!("expected objects")
};
base.extend(extra);
serde_json::from_value(agent).unwrap()
}
fn build(agent: &AgentPayload) -> Result<BTreeMap<String, String>, String> {
build_env(
agent,
AuthoritativeInputs {
generation: "gen0001",
inactivity_seconds: Some(7200),
},
)
}
#[test]
fn identity_comes_from_top_level_fields() {
let env = build(&payload_json(serde_json::json!({}))).unwrap();
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
}
/// Wren's amendment, and the spec's later-wins rule: a lower tier that
/// spoofs an authoritative key is *overwritten*, not refused. Refusing
/// would diverge from the local spawn, where the same env is written
/// before the authoritative layer and simply loses.
#[test]
fn lower_tiers_cannot_spoof_authoritative_values() {
let agent = payload_json(serde_json::json!({
"launch": {
"command": "goose",
"policy_env": {
"BUZZ_PRIVATE_KEY": "nsec1attacker",
"BUZZ_MANAGED_AGENT_START_NONCE": "forged",
},
"env": {
"BUZZ_RELAY_URL": "wss://attacker.example",
"NOSTR_PRIVATE_KEY": "nsec1attacker",
"BUZZ_AUTH_TAG": "forged-tag",
"BUZZ_ACP_AGENT_OWNER": "cafe",
"BUZZ_ACP_AGENT_COMMAND": "/bin/sh",
"BUZZ_ACP_MCP_COMMAND": "/bin/sh",
"BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0",
},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef");
assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose");
assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp");
assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200");
assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001");
}
/// Tier 1 is *overridable* — user env beats policy defaults, matching the
/// local spawn, where the user layer is written after them. Getting this
/// backwards would make remote agents ignore overrides local agents honor.
#[test]
fn user_env_overrides_policy_defaults() {
let agent = payload_json(serde_json::json!({
"launch": {
"policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"},
"env": {"GOOSE_MODE": "chat"},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(env["GOOSE_MODE"], "chat");
assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet");
}
/// `launch.env` already contains the merged user env, so re-merging the
/// legacy field would undo a layering the desktop already resolved.
#[test]
fn legacy_env_vars_are_not_remerged_when_launch_present() {
let agent = payload_json(serde_json::json!({
"env_vars": {"STALE": "yes", "SHARED": "legacy"},
"launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"}
}));
let env = build(&agent).unwrap();
assert_eq!(env["SHARED"], "resolved");
assert!(!env.contains_key("STALE"), "legacy env_vars re-merged");
}
/// ...but a desktop predating the `launch` block has nothing else to
/// offer, so the legacy field is the truth in exactly that case.
#[test]
fn legacy_env_vars_used_when_launch_absent() {
let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}}));
let env = build(&agent).unwrap();
assert_eq!(env["API"], "v");
}
#[test]
fn refuses_when_no_owner_resolves() {
let agent = payload_json(serde_json::json!({"auth_tag": null}));
let err = build(&agent).unwrap_err();
assert!(err.contains("!shutdown"), "unhelpful error: {err}");
}
/// An empty string is not an owner. Without this the refusal is
/// bypassable by a blank field and the pod launches unable to be stopped.
#[test]
fn empty_owner_fields_count_as_absent() {
for blank in ["", " "] {
let agent = payload_json(serde_json::json!({
"auth_tag": blank,
"launch": {"owner_pubkey": blank}
}));
assert!(
build(&agent).is_err(),
"whitespace resolved as an owner: {blank:?}"
);
}
}
/// The other half of every identity guard: what is *stored*. A validator
/// that trims and a writer that doesn't disagree about the value, and the
/// padding reaches the harness inside the Secret. Assert on the stored
/// string — asserting only that the deploy was accepted passes either way.
#[test]
fn identity_components_are_stored_trimmed() {
let agent = payload_json(serde_json::json!({
"relay_url": " wss://relay.example ",
"auth_tag": " tag-1 ",
"launch": {"owner_pubkey": " beefcafe "}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
}
/// L1 item 1's third identity component. The nsec arm is enforced in
/// `naming.rs` and the owner arm above; without this one an agent
/// deploys with nothing to connect to — a pod that starts, fails at the
/// relay, and reads as a network fault rather than a refused launch.
#[test]
fn refuses_empty_relay_url() {
for blank in ["", " "] {
let agent = payload_json(serde_json::json!({"relay_url": blank}));
let err = build(&agent).unwrap_err();
assert!(err.contains("relay_url"), "unhelpful error: {err}");
}
}
#[test]
fn owner_pubkey_alone_is_sufficient() {
let agent = payload_json(serde_json::json!({
"auth_tag": null,
"launch": {"owner_pubkey": "beefcafe"}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
assert!(!env.contains_key("BUZZ_AUTH_TAG"));
}
#[test]
fn refuses_presence_suppression() {
let agent = payload_json(serde_json::json!({
"launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"}
}));
let err = build(&agent).unwrap_err();
assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}");
}
/// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30
/// kubelets drop it, 1.30+ inject it. Refuse rather than behave
/// differently depending on the cluster.
#[test]
fn refuses_non_posix_env_keys() {
for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] {
let agent = payload_json(serde_json::json!({
"launch": {"env": {bad: "v"}, "owner_pubkey": "beef"}
}));
assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}");
}
}
#[test]
fn args_are_comma_joined_and_omitted_when_empty() {
let agent = payload_json(serde_json::json!({
"launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session");
let agent = payload_json(serde_json::json!({
"launch": {"command": "goose", "args": [], "owner_pubkey": "b"}
}));
assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS"));
}
/// The top-level `model`/`provider` fields are display inputs; their
/// environment consequence is per-runtime and arrives already resolved
/// inside `launch`. A provider-side mapping is wrong for three of the
/// four built-in runtimes.
#[test]
fn provider_never_maps_model_or_provider_itself() {
let agent = payload_json(serde_json::json!({
"model": "claude-opus", "provider": "anthropic",
"launch": {"owner_pubkey": "beef"}
}));
let env = build(&agent).unwrap();
for key in [
"BUZZ_AGENT_PROVIDER",
"BUZZ_AGENT_MODEL",
"GOOSE_PROVIDER",
"GOOSE_MODEL",
] {
assert!(!env.contains_key(key), "provider mapped {key} itself");
}
}
/// `turn_timeout_seconds` is deprecated and ignored upstream; the local
/// spawn does not emit it either.
#[test]
fn turn_timeout_is_not_mapped() {
let agent = payload_json(serde_json::json!({
"turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"}
}));
let env = build(&agent).unwrap();
assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT")));
}
#[test]
fn inactivity_omitted_when_unset() {
let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}}));
let env = build_env(
&agent,
AuthoritativeInputs {
generation: "g",
inactivity_seconds: None,
},
)
.unwrap();
assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY"));
}
/// Structural guard over the whole authoritative list at once: whatever
/// the lower tiers contain, no authoritative key holds a lower-tier value
/// — including the conditionally-written ones the authoritative tier has
/// nothing to say about, which must be **absent** rather than spoofed.
/// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written
/// when `launch.args` is non-empty, so plain later-wins overwrite left the
/// spoofed value in place.
#[test]
fn no_authoritative_key_retains_a_lower_tier_value() {
let spoofed: serde_json::Map<String, serde_json::Value> = AUTHORITATIVE_KEYS
.iter()
.map(|k| ((*k).to_string(), serde_json::json!("SPOOFED")))
.collect();
// Split across both lower tiers: policy_env and env are separate
// insertion points, and a fix that only cleared one would pass a
// single-tier test.
let agent = payload_json(serde_json::json!({
"launch": {
"command": "goose",
"policy_env": spoofed.clone(),
"env": spoofed,
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
for key in AUTHORITATIVE_KEYS {
assert_ne!(
env.get(*key).map(String::as_str),
Some("SPOOFED"),
"{key} kept its lower-tier value"
);
}
// The keys the authoritative tier had no value for are gone, not
// merely different.
for absent in [
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
] {
assert!(!env.contains_key(absent), "{absent} survived the clear");
}
}
/// A 64-hex pubkey, the only allowlist entry shape the harness accepts.
fn pubkey(fill: char) -> String {
std::iter::repeat_n(fill, 64).collect()
}
/// The gate the harness refuses first (`config.rs:996-1004`). Refusing it
/// here is the difference between one error message and an unbounded
/// fail-replace loop that leaves a Secret per attempt.
#[test]
fn allowlist_mode_with_an_empty_list_is_refused() {
for empty in [serde_json::json!([]), serde_json::Value::Null] {
let agent = payload_json(serde_json::json!({
"respond_to": "allowlist",
"respond_to_allowlist": empty,
}));
let err = build(&agent).unwrap_err();
assert!(
err.contains("the allowlist is empty"),
"unexpected error: {err}"
);
}
}
/// `config.rs:629-641` — each entry must be exactly 64 hex characters.
/// The rejects are the distinct ways to miss that: too short, right length
/// but not hex, empty, and one character short of valid.
#[test]
fn an_allowlist_entry_that_is_not_64_hex_is_refused() {
for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] {
let agent = payload_json(serde_json::json!({
"respond_to": "allowlist",
"respond_to_allowlist": [pubkey('a'), bad],
}));
let err = build(&agent).unwrap_err();
assert!(
err.contains("must be exactly 64 hex characters"),
"{bad:?} was accepted; error was: {err}"
);
}
}
/// The positive control: the guard refuses bad gates, not every gate.
/// Without this, a validator that refused unconditionally would pass both
/// tests above.
#[test]
fn a_valid_allowlist_gate_is_accepted_and_comma_joined() {
let agent = payload_json(serde_json::json!({
"respond_to": "allowlist",
"respond_to_allowlist": [pubkey('a'), pubkey('b')],
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist");
assert_eq!(
env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"],
format!("{},{}", pubkey('a'), pubkey('b'))
);
}
/// The harness validates the allowlist **only** in allowlist mode and
/// merely warns otherwise (`config.rs:1005-1010`). A stricter provider
/// would refuse a deploy whose identical local spawn succeeds, so this
/// pins the asymmetry rather than leaving it to look like an oversight.
#[test]
fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() {
for mode in ["owner-only", "anyone"] {
let agent = payload_json(serde_json::json!({
"respond_to": mode,
"respond_to_allowlist": ["not-a-pubkey"],
}));
let env = build(&agent)
.unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}"));
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
}
}
/// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum`
/// at the harness, so an unrecognized mode dies at `rc=2` — before config
/// parsing runs at all, earlier than either refusal above. Measured
/// against the built binary: `invalid value 'npub1abc' for '--respond-to'`.
/// This is the shape our own fixture carried until it was corrected.
#[test]
fn a_mode_the_harness_cannot_parse_is_refused() {
for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] {
let agent = payload_json(serde_json::json!({ "respond_to": bad }));
let err = build(&agent).unwrap_err();
assert!(
err.contains("is not a mode the harness accepts"),
"{bad:?} was accepted; error was: {err}"
);
}
}
/// `clap` does not trim its value-enum input, so a padded mode is `rc=2`
/// even though the same string trimmed is valid. Measured: `invalid value
/// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy
/// the harness refuses — the exact direction this guard exists to prevent.
#[test]
fn a_padded_mode_is_refused_because_clap_does_not_trim() {
for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] {
let agent = payload_json(serde_json::json!({
"respond_to": padded,
"respond_to_allowlist": [pubkey('a')],
}));
let err = build(&agent).unwrap_err();
assert!(
err.contains("is not a mode the harness accepts"),
"{padded:?} was accepted; error was: {err}"
);
}
}
/// Positive control for the mode check, and the reason it validates the
/// harness's four rather than the desktop's three: `nobody` is rejected by
/// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts
/// fine at the harness. A guard mirroring the desktop enum would refuse a
/// working launch from a non-desktop caller — the callers this guard is
/// for. Without this test, refusing `nobody` would pass everything above.
#[test]
fn every_mode_the_harness_accepts_is_deployable() {
for mode in ["owner-only", "allowlist", "anyone", "nobody"] {
let agent = payload_json(serde_json::json!({
"respond_to": mode,
"respond_to_allowlist": [pubkey('a')],
}));
let env = build(&agent)
.unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}"));
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
}
}
}
+368
View File
@@ -0,0 +1,368 @@
//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`).
//!
//! GC runs on every deploy, after identity derivation and before the state
//! transition. It deletes terminated pods (and their referenced Secrets) and
//! age-eligible orphan Secrets — every one of which must pass the full-pubkey
//! annotation check *and* carry the management marker. An unmarked object is
//! never GC'd regardless of its labels.
//!
//! The decision layer here is pure. The effectful caller supplies the observed
//! objects and the apiserver's clock; this module decides what may be deleted.
use crate::naming::AgentIdentity;
use crate::observe::{referenced_secret, secret_is_ours};
use chrono::{DateTime, Utc};
use k8s_openapi::api::core::v1::{Pod, Secret};
/// The deploy operation deadline (spec §Deploy: `timeout: 600s`).
pub const OPERATION_DEADLINE_SECS: i64 = 600;
/// An unreferenced Secret is GC-eligible only once it is older than **twice**
/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic
/// against an independent GC pass, so without the gate a concurrent attempt's
/// preflight GC can delete a Secret whose pod has not been created yet and
/// strand that deploy. The age bound makes "unreferenced" mean "provably
/// abandoned" — any attempt that could still reference it has exceeded its own
/// deadline (`:1301-1319`).
pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS;
/// What a GC pass decided to delete. Names only: the caller re-reads each
/// object's own fence at delete time.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct GcPlan {
/// Terminated, verified, marker-bearing pods.
pub pods: Vec<String>,
/// Age-eligible, verified, marker-bearing orphan Secrets.
pub secrets: Vec<String>,
}
/// Plan a GC pass.
///
/// `now` is the apiserver's clock — the HTTP `Date` header from the very list
/// call that produced `secrets`. `None` means the header was absent or
/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather
/// than falling back to local time: this provider runs on a user's desktop,
/// and a local clock fast by more than the margin does not race — it
/// deterministically computes every in-flight Secret as expired, on every
/// pass, reopening exactly the interleaving the gate exists to close
/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not.
///
/// Terminated-pod GC does not use the clock and is unaffected.
pub fn plan(
identity: &AgentIdentity,
pods: &[Pod],
secrets: &[Secret],
terminated: impl Fn(&Pod) -> bool,
now: Option<DateTime<Utc>>,
) -> GcPlan {
// Only pods that pass the full fence participate — in either direction.
// An unverified pod is neither deleted nor allowed to protect a Secret:
// it cannot be ours, so its `envFrom` cannot reference our generation.
let ours: Vec<&Pod> = pods
.iter()
.filter(|p| {
crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some()
})
.collect();
let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect();
// A Secret referenced by ANY existing pod is protected — deliberately
// including not-yet-started pods, whose `envFrom` is exactly as
// load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this
// same pass are excluded, so their Secrets go with them.
let doomed_names: Vec<&str> = doomed_pods
.iter()
.filter_map(|p| p.metadata.name.as_deref())
.collect();
let protected: Vec<String> = ours
.iter()
.filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default()))
.filter_map(|p| referenced_secret(p))
.collect();
let mut plan = GcPlan {
pods: doomed_names.iter().map(|n| n.to_string()).collect(),
secrets: doomed_pods
.iter()
.filter_map(|p| referenced_secret(p))
.collect(),
};
// Orphan sweep: only with a server clock.
if let Some(now) = now {
for secret in secrets {
if !secret_is_ours(secret, identity) {
continue;
}
let Some(name) = secret.metadata.name.as_deref() else {
continue;
};
if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) {
continue;
}
let Some(created) = secret.metadata.creation_timestamp.as_ref() else {
// No server-assigned timestamp means no age proof. Skip.
continue;
};
if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS {
plan.secrets.push(name.to_string());
}
}
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY};
use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
use std::collections::BTreeMap;
fn identity() -> AgentIdentity {
use nostr::nips::nip19::ToBech32;
let keys = nostr::Keys::generate();
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
}
fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod {
Pod {
metadata: ObjectMeta {
name: Some(name.into()),
uid: Some(format!("uid-{name}")),
resource_version: Some("1".into()),
labels: Some(id.labels()),
annotations: Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
id.pubkey_hex().to_string(),
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
),
..Default::default()
},
spec: secret.map(|s| PodSpec {
containers: vec![Container {
name: "agent".into(),
env_from: Some(vec![EnvFromSource {
secret_ref: Some(SecretEnvSource {
name: s.into(),
optional: Some(false),
}),
..Default::default()
}]),
..Default::default()
}],
..Default::default()
}),
..Default::default()
}
}
fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime<Utc>) -> Secret {
Secret {
metadata: ObjectMeta {
name: Some(name.into()),
labels: Some(id.labels()),
annotations: Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
id.pubkey_hex().to_string(),
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
),
creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))),
..Default::default()
},
..Default::default()
}
}
fn never(_: &Pod) -> bool {
false
}
fn always(_: &Pod) -> bool {
true
}
#[test]
fn terminated_pods_and_their_secrets_are_collected_together() {
let id = identity();
let now = Utc::now();
let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
let plan = plan(&id, &[pod], &[], always, Some(now));
assert_eq!(plan.pods, ["buzz-agent-dead"]);
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
}
#[test]
fn live_pods_are_never_collected() {
let id = identity();
let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1"));
let plan = plan(&id, &[pod], &[], never, Some(Utc::now()));
assert_eq!(plan, GcPlan::default());
}
/// The auto-repair fence applies to GC identically: an object that lacks
/// the marker, or carries a different pubkey, is never touched — however
/// well its labels match.
#[test]
fn unmarked_and_mismatched_objects_are_never_collected() {
let id = identity();
let other = identity();
let now = Utc::now();
let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1"));
let mut labels = id.labels();
labels.remove(LABEL_MANAGED_BY);
unmarked.metadata.labels = Some(labels);
let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1"));
foreign.metadata.annotations = Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
other.pubkey_hex().to_string(),
)]
.into_iter()
.collect(),
);
let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now);
unmarked_secret.metadata.labels = Some(BTreeMap::new());
let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now);
foreign_secret.metadata.annotations = Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
other.pubkey_hex().to_string(),
)]
.into_iter()
.collect(),
);
let plan = plan(
&id,
&[unmarked, foreign],
&[unmarked_secret, foreign_secret],
always,
Some(now),
);
assert_eq!(
plan,
GcPlan::default(),
"GC touched an object it does not own"
);
}
/// The interleaving the age gate exists to close: attempt A creates its
/// Secret; concurrent attempt B's preflight GC runs before A creates its
/// pod. Without the gate B deletes A's Secret and strands A.
#[test]
fn young_unreferenced_secrets_are_protected() {
let id = identity();
let now = Utc::now();
let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now);
assert_eq!(
plan(&id, &[], &[fresh], never, Some(now)),
GcPlan::default()
);
}
/// Past twice the deadline, any attempt that could still reference the
/// Secret has exceeded its own deadline — so it is provably abandoned.
#[test]
fn secrets_older_than_twice_the_deadline_are_collected() {
let id = identity();
let now = Utc::now();
let old = secret_named(
&id,
"buzz-agent-x-gen-abandoned",
ORPHAN_SECRET_MIN_AGE_SECS + 1,
now,
);
let plan = plan(&id, &[], &[old], never, Some(now));
assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]);
}
/// The boundary itself, both sides. `>= 1200s` is eligible.
#[test]
fn age_gate_boundary_is_exact() {
let id = identity();
let now = Utc::now();
let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now);
let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now);
assert!(plan(&id, &[], &[just_under], never, Some(now))
.secrets
.is_empty());
assert_eq!(
plan(&id, &[], &[exactly], never, Some(now)).secrets,
["exact"]
);
}
/// The same-clock rule. No apiserver `Date` header → skip the orphan
/// sweep entirely. A local clock fast by more than the margin would
/// silently delete every in-flight Secret on every pass.
#[test]
fn without_a_server_clock_the_orphan_sweep_is_skipped() {
let id = identity();
let now = Utc::now();
let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now);
let plan = plan(&id, &[], &[ancient], never, None);
assert!(
plan.secrets.is_empty(),
"orphan swept without a server clock — a fast local clock would delete live Secrets"
);
}
/// ...but terminated-pod GC does not consult the clock, so it still runs.
#[test]
fn terminated_pod_gc_runs_without_a_server_clock() {
let id = identity();
let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
let plan = plan(&id, &[pod], &[], always, None);
assert_eq!(plan.pods, ["buzz-agent-dead"]);
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
}
/// "Existing" includes not-yet-started pods: a Secret referenced by a pod
/// still pulling its image must not be swept, however old it is.
#[test]
fn secrets_referenced_by_a_pending_pod_are_protected() {
let id = identity();
let now = Utc::now();
let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1"));
let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now);
let plan = plan(&id, &[pending], &[old], never, Some(now));
assert!(plan.secrets.is_empty(), "swept a referenced Secret");
}
/// A Secret with no server-assigned creationTimestamp has no age proof,
/// so it is skipped rather than assumed old.
#[test]
fn secrets_without_a_creation_timestamp_are_skipped() {
let id = identity();
let now = Utc::now();
let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now);
no_timestamp.metadata.creation_timestamp = None;
assert!(plan(&id, &[], &[no_timestamp], never, Some(now))
.secrets
.is_empty());
}
/// A Secret belonging to a pod being collected in this same pass goes with
/// it, and must not be listed twice.
#[test]
fn a_collected_pods_secret_is_listed_once() {
let id = identity();
let now = Utc::now();
let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now);
let plan = plan(&id, &[dead], &[its_secret], always, Some(now));
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Image reference validation (spec §Image).
//!
//! The object holding this reference runs with an nsec, so the reference must
//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself
//! distinguishes them from digests for exactly this reason — so a tag-only
//! reference is rejected, not just `:latest`.
//!
//! There is no parse-time fallback: `image` is required, and its absence
//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig`
//! digest is offered only as a schema `default` (a UI prefill the desktop
//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent
//! fingerprint never depends on compiled-in provider state.
/// A validated, digest-qualified image reference.
///
/// The inner string is always in canonical tagless form `name@sha256:<hex>`:
/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is
/// decorative once a digest pins the content, and leaving it in would make
/// two references to identical bytes produce different create-intent
/// fingerprints.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef(String);
impl ImageRef {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ImageRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Parse and normalize a user-supplied image reference.
pub fn parse(raw: &str) -> Result<ImageRef, String> {
let reference = raw.trim();
if reference.is_empty() {
return Err("provider_config.image is required: no image is assumed \
at parse time, so the digest-pinned image to run must be \
given explicitly"
.to_string());
}
let mut parts = reference.split('@');
let name_and_tag = parts.next().unwrap_or_default();
let digest = match (parts.next(), parts.next()) {
(Some(d), None) => d,
(None, _) => {
return Err(format!(
"provider_config.image {reference:?} is not digest-pinned: a \
tag is a mutable pointer, and this object runs with the \
agent's private key. Use name@sha256:<64 hex chars>"
))
}
(Some(_), Some(_)) => {
return Err(format!(
"provider_config.image {reference:?} contains more than one '@'"
))
}
};
let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
format!("provider_config.image digest {digest:?} must start with 'sha256:'")
})?;
// Lowercase only: OCI canonicalizes digest hex, and accepting uppercase
// would let two spellings of one digest produce two fingerprints.
if hex.len() != 64
|| !hex
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
{
return Err(format!(
"provider_config.image digest {digest:?} must be exactly 64 \
lowercase hex characters"
));
}
// Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same
// bytes and must fingerprint identically. Only a *final* colon segment
// that isn't a port counts as a tag — `host:5000/name` has no tag.
let name = match name_and_tag.rfind(':') {
Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon],
_ => name_and_tag,
};
if name.is_empty() {
return Err(format!(
"provider_config.image {reference:?} has no repository name"
));
}
Ok(ImageRef(format!("{name}@sha256:{hex}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_digest_pinned_reference() {
let d = "a".repeat(64);
let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}"));
}
/// The normalization that keeps the fingerprint stable: two spellings of
/// the same bytes must produce one reference.
#[test]
fn strips_tag_from_tag_plus_digest_form() {
let d = "b".repeat(64);
let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap();
let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
assert_eq!(tagged, plain);
}
/// A registry port is not a tag. `host:5000/name` must keep its port.
#[test]
fn registry_port_is_not_mistaken_for_a_tag() {
let d = "c".repeat(64);
let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap();
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
}
#[test]
fn port_and_tag_together_drops_only_the_tag() {
let d = "d".repeat(64);
let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap();
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
}
/// Wren's amendment: *every* tag-only reference is rejected, not just
/// `:latest`. A `sha-<gitsha>` tag is traceable but still movable.
#[test]
fn rejects_every_tag_only_reference() {
for bad in [
"ghcr.io/block/buzz-sprig:latest",
"ghcr.io/block/buzz-sprig:v1.2.3",
"ghcr.io/block/buzz-sprig:sha-abc1234",
"ghcr.io/block/buzz-sprig",
"localhost:5000/buzz-sprig",
] {
let err = parse(bad).unwrap_err();
assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}");
}
}
/// Uppercase hex is a second spelling of one digest; accepting it would
/// let the same image fingerprint two ways.
#[test]
fn rejects_uppercase_digest_hex() {
let d = "A".repeat(64);
assert!(parse(&format!("img@sha256:{d}")).is_err());
}
#[test]
fn rejects_malformed_digests() {
let short = "a".repeat(63);
let long = "a".repeat(65);
let ok = "a".repeat(64);
for bad in [
format!("img@sha256:{short}"),
format!("img@sha256:{long}"),
format!("img@sha512:{ok}"),
format!("img@{ok}"),
format!("img@sha256:{}", "g".repeat(64)),
format!("img@sha256:{ok}@sha256:{ok}"),
format!("@sha256:{ok}"),
] {
assert!(parse(&bad).is_err(), "accepted {bad:?}");
}
}
/// Parsing has no fallback (the schema default is a UI prefill, not a
/// parse-time substitute), so an absent image is an error that names the
/// field rather than a silent fallback.
#[test]
fn empty_reference_names_the_field() {
for empty in ["", " "] {
let err = parse(empty).unwrap_err();
assert!(err.contains("provider_config.image"), "got: {err}");
}
}
}
@@ -0,0 +1,309 @@
//! The create-intent fingerprint (spec §Deploy State Machine, create-intent
//! fingerprint, `docs/remote-agents.md:796-828`).
//!
//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the
//! provider's non-secret create-intent template. A plain hash is safe *only*
//! because of the scope rule: the input covers exactly the provider-controlled
//! fields that can affect scheduling or container creation, and **never Secret
//! data or attempt identity**. Hashing low-entropy secrets into a
//! world-readable annotation would be a dictionary oracle.
//!
//! That rule is enforced structurally rather than remembered. [`IntentTemplate`]
//! is a *pre-binding* type: it has no field that can hold Secret material or a
//! generation token, so there is no expression that hashes one. The
//! per-attempt Secret name never appears — the pod's `envFrom` is represented
//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would
//! diverge from every other by construction.
//!
//! Server- and admission-produced output (UID, `resourceVersion`, timestamps,
//! defaulted fields, the annotation itself) is excluded the same way: the
//! serializer is only ever handed this template, never a live `Pod`, so the
//! exclusion is checkable by inspection.
use crate::image::ImageRef;
use serde::Serialize;
use sha2::{Digest, Sha256};
/// Stands in for the per-attempt Secret name in the `envFrom` position.
/// A real generation token here would make every attempt diverge.
const SECRET_PLACEHOLDER: &str = "<per-attempt-secret>";
/// The recorded/computed create intent: a hex SHA-256 digest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fingerprint(String);
impl Fingerprint {
pub fn as_str(&self) -> &str {
&self.0
}
/// Read a fingerprint off a pod annotation. Any recorded string is
/// accepted verbatim: comparison is equality against a freshly computed
/// value, so a malformed annotation simply reads as divergence — which is
/// the correct outcome for a pod this provider version did not write.
pub fn from_annotation(value: &str) -> Self {
Self(value.to_string())
}
#[cfg(test)]
pub fn for_test(seed: &str) -> Self {
Self(format!("test-{seed}"))
}
}
impl std::fmt::Display for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// The non-secret, pre-binding description of the pod this deploy would
/// create. Every field is provider-controlled and scheduling-relevant; there
/// is deliberately no field for env values, Secret data, or the generation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IntentTemplate {
/// Schema version of the template itself. Bumping it re-fingerprints every
/// pod, which is the intended way to roll out a pod-shape change.
pub template_version: u32,
pub namespace: String,
/// Normalized, digest-qualified image reference.
pub image: String,
pub cpu_request: String,
pub memory_request: String,
pub cpu_limit: String,
pub memory_limit: String,
pub service_account: Option<String>,
pub restart_policy: &'static str,
pub termination_grace_period_seconds: i64,
/// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the
/// container's contract); values are Secret material and must not be here.
pub env_keys: Vec<String>,
/// Fixed placeholder for the per-attempt Secret in `envFrom`.
pub env_from_secret: &'static str,
pub workspace_mount_path: String,
pub run_as_user: i64,
pub run_as_group: i64,
}
/// Current template schema version.
pub const TEMPLATE_VERSION: u32 = 1;
impl IntentTemplate {
/// Compute the fingerprint. `serde_json` on a struct with declared field
/// order plus pre-sorted `env_keys` is a canonical serialization: the same
/// template always produces the same bytes.
pub fn fingerprint(&self) -> Fingerprint {
let canonical = serde_json::to_vec(self).expect("intent template is plain data");
Fingerprint(hex::encode(Sha256::digest(&canonical)))
}
/// Build from resolved pod-shape inputs. `env_keys` is sorted here rather
/// than at the call site so key ordering can never leak into the digest.
///
/// The fixed pod-shape constants are read from [`crate::config`] rather
/// than passed in: `pod::build_pod` stamps the pod from those same
/// constants, so the fingerprint cannot describe a pod shape different
/// from the one actually created. Threading them through as arguments
/// would make that agreement a thing to test instead of a thing that holds.
pub fn new(
namespace: &str,
image: &ImageRef,
resources: &crate::config::Resources,
service_account: Option<&str>,
env_keys: impl IntoIterator<Item = String>,
) -> Self {
let mut env_keys: Vec<String> = env_keys.into_iter().collect();
env_keys.sort();
Self {
template_version: TEMPLATE_VERSION,
namespace: namespace.to_string(),
image: image.as_str().to_string(),
cpu_request: resources.cpu_request.clone(),
memory_request: resources.memory_request.clone(),
cpu_limit: resources.cpu_limit.clone(),
memory_limit: resources.memory_limit.clone(),
service_account: service_account.map(str::to_string),
restart_policy: crate::config::RESTART_POLICY,
termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS,
env_keys,
env_from_secret: SECRET_PLACEHOLDER,
workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(),
run_as_user: crate::config::RUN_AS_UID,
run_as_group: crate::config::RUN_AS_GID,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Resources;
fn image(byte: char) -> ImageRef {
crate::image::parse(&format!(
"ghcr.io/block/buzz-sprig@sha256:{}",
byte.to_string().repeat(64)
))
.unwrap()
}
fn template() -> IntentTemplate {
IntentTemplate::new(
"buzz-agents",
&image('a'),
&Resources::default(),
None,
["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()],
)
}
#[test]
fn fingerprint_is_deterministic() {
assert_eq!(template().fingerprint(), template().fingerprint());
}
#[test]
fn fingerprint_is_hex_sha256() {
let fp = template().fingerprint();
assert_eq!(fp.as_str().len(), 64);
assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit()));
}
/// Key *order* must not reach the digest, or two identical environments
/// built in different orders would look like a config change.
#[test]
fn env_key_order_does_not_affect_the_digest() {
let a = IntentTemplate::new(
"ns",
&image('a'),
&Resources::default(),
None,
["A".to_string(), "B".to_string(), "C".to_string()],
);
let b = IntentTemplate::new(
"ns",
&image('a'),
&Resources::default(),
None,
["C".to_string(), "A".to_string(), "B".to_string()],
);
assert_eq!(a.fingerprint(), b.fingerprint());
}
/// A mutation applied to a fresh template clone, named for its assertion
/// message.
type Mutation = (&'static str, Box<dyn Fn(&mut IntentTemplate)>);
/// Every scheduling-relevant knob must move the digest — this is the
/// wedge escape (§Deploy State Machine never-started recoverable row).
/// Exhaustive by construction: each mutation is applied to a fresh clone.
#[test]
fn every_scheduling_field_changes_the_digest() {
let base = template();
let baseline = base.fingerprint();
let mutations: Vec<Mutation> = vec![
(
"template_version",
Box::new(|t: &mut IntentTemplate| t.template_version += 1),
),
(
"namespace",
Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()),
),
(
"image",
Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()),
),
(
"cpu_request",
Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()),
),
(
"memory_request",
Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()),
),
(
"cpu_limit",
Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()),
),
(
"memory_limit",
Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()),
),
(
"service_account",
Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())),
),
(
"restart_policy",
Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"),
),
(
"grace_period",
Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30),
),
(
"env_keys",
Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())),
),
(
"workspace_mount_path",
Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()),
),
(
"run_as_user",
Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000),
),
(
"run_as_group",
Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000),
),
];
for (name, mutate) in mutations {
let mut t = base.clone();
mutate(&mut t);
assert_ne!(
t.fingerprint(),
baseline,
"{name} did not affect the digest"
);
}
}
/// The scope rule, asserted on the bytes: no Secret value and no
/// generation token can appear in the serialization, because the type has
/// nowhere to put them. The placeholder is what `envFrom` contributes.
#[test]
fn serialization_contains_no_secret_material_or_attempt_identity() {
let json = serde_json::to_string(&template()).unwrap();
for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] {
assert!(
!json.contains(forbidden),
"template leaked {forbidden}: {json}"
);
}
assert!(json.contains(SECRET_PLACEHOLDER));
}
/// Two attempts for the same agent differ only in generation, which is
/// absent from the template — so their fingerprints must be equal, or the
/// divergence discriminator would fire on every single deploy.
#[test]
fn attempts_differing_only_by_generation_do_not_diverge() {
// There is no generation input to pass; that *is* the property. The
// test states it explicitly so a future field addition breaks here.
assert_eq!(template().fingerprint(), template().fingerprint());
let json = serde_json::to_string(&template()).unwrap();
assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1);
}
/// A recorded annotation this provider version did not write reads as
/// divergence rather than an error.
#[test]
fn unrecognized_annotation_reads_as_divergence() {
let recorded = Fingerprint::from_annotation("not-a-digest");
assert_ne!(recorded, template().fingerprint());
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Kubernetes backend provider for Buzz remote agents
//! (spec `docs/remote-agents.md`).
//!
//! One process per operation: read exactly one JSON request from stdin, write
//! exactly one JSON response to stdout, exit. The exit code carries exactly
//! one bit — 0 for a response that was produced, 1 for a failure to produce
//! one. Everything a caller needs to distinguish is *inside* the response's
//! `ok` field, because a provider that encoded outcomes in exit codes would
//! have a second, redundant error channel to keep in sync (§Provider Protocol).
mod classify;
mod client;
mod cluster;
mod config;
mod env;
mod gc;
mod image;
mod intent;
mod naming;
mod observe;
mod pod;
mod reconcile;
mod wire;
use std::io::Read;
use wire::{Request, Response};
/// The provider a shared-compute agent resolves to. Refused here as the
/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it
/// as a pod would create a second, contending consumer of the same agent
/// identity (`:214-219`).
const RELAY_MESH_PROVIDER: &str = "relay-mesh";
fn main() {
// rustls needs a process-level provider before the first TLS connection.
// The release build compiles every sidecar in one cargo invocation, which
// unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to
// auto-select — so this is an explicit install, not a default.
let _ = rustls::crypto::ring::default_provider().install_default();
let mut input = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
// No request means no request_id and no response contract to honor.
// This is the one path that exits nonzero.
eprintln!("could not read the request from stdin: {e}");
std::process::exit(1);
}
let response = respond(&input);
println!(
"{}",
serde_json::to_string(&response).unwrap_or_else(|e| {
// The response types are plain data; this cannot fail in practice,
// and a hand-built object is still a conforming response.
format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#)
})
);
}
/// Produce the single response for one request. Separated from `main` so the
/// whole dispatch is testable without a process.
fn respond(input: &str) -> Response {
// Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire
// value, and `AgentPayload` deliberately does not carry `provider`.
let raw: serde_json::Value = match serde_json::from_str(input) {
Ok(value) => value,
Err(e) => return Response::error(format!("request is not valid JSON: {e}")),
};
if let Some(refusal) = refuse_relay_mesh(&raw) {
return Response::error(refusal);
}
let request: Request = match serde_json::from_value(raw) {
Ok(request) => request,
Err(e) => return Response::error(format!("could not understand the request: {e}")),
};
match request {
Request::Info => Response::info(),
Request::Deploy(deploy) => {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(e) => return Response::error(format!("could not start the runtime: {e}")),
};
match runtime.block_on(deploy_agent(&deploy)) {
Ok(agent_id) => Response::deployed(agent_id),
Err(e) => Response::error(e),
}
}
}
}
/// Refuse a shared-compute agent, reading the **raw wire value**.
///
/// Trimmed before comparing: the desktop's own layers disagree about padding
/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard
/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding
/// whitespace on a non-blank value. A backstop that shares its bypass with the
/// layer it backs is not a backstop.
fn refuse_relay_mesh(raw: &serde_json::Value) -> Option<String> {
let provider = raw.get("agent")?.get("provider")?.as_str()?;
(provider.trim() == RELAY_MESH_PROVIDER).then(|| {
"deploy refused: this agent is configured for shared compute \
(relay-mesh), which runs on the relay rather than in a pod. \
Switch the agent to a local runtime before deploying it to \
Kubernetes."
.to_string()
})
}
/// Run one deploy to a terminal outcome.
async fn deploy_agent(request: &wire::DeployRequest) -> Result<String, String> {
let cfg = config::parse(&request.provider_config)?;
// Identity before any cluster contact: a malformed nsec is a refusal, not
// a failed connection (§Deploy State Machine step 0).
let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?;
// One generation for this operation's first attempt; the reconciler mints
// its own per attempt and restamps the correlator to match.
let env = env::build_env(
&request.agent,
env::AuthoritativeInputs {
generation: &naming::new_generation(),
inactivity_seconds: cfg.inactivity_seconds,
},
)?;
let client = client::connect(cfg.context.as_deref()).await?;
let substrate = cluster::Cluster::new(client, &cfg.namespace);
reconcile::deploy(&substrate, &identity, &cfg, env).await
}
#[cfg(test)]
mod tests {
use super::*;
fn error_of(response: &Response) -> String {
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["ok"], false, "expected a refusal: {json}");
json["error"].as_str().unwrap().to_string()
}
/// The spec's backstop for the relay-mesh MUST. The desktop refuses first
/// (`agents_deploy.rs:116`); this is the layer that owes the obligation.
#[test]
fn refuses_a_relay_mesh_agent() {
let request = r#"{"op":"deploy","agent":{
"relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"},
"provider_config":{"namespace":"ns"}}"#;
assert!(error_of(&respond(request)).contains("relay-mesh"));
}
/// Padding must not bypass the backstop. Reachable by construction:
/// `GlobalConfig.provider` is a bare `Option<String>` with no trim on
/// write, and `non_blank` rejects whitespace-only while preserving
/// surrounding whitespace on everything else.
#[test]
fn refuses_a_padded_relay_mesh_agent() {
let request = r#"{"op":"deploy","agent":{
"relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "},
"provider_config":{"namespace":"ns"}}"#;
assert!(error_of(&respond(request)).contains("relay-mesh"));
}
/// The refusal must not fire on a normal agent — a guard that refuses
/// everything passes its own test and ships a provider that deploys
/// nothing.
#[test]
fn does_not_refuse_a_normal_provider() {
let raw: serde_json::Value =
serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap();
assert!(refuse_relay_mesh(&raw).is_none());
// …nor when the field is absent entirely, which is the common case:
// `AgentPayload` does not carry `provider`.
let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap();
assert!(refuse_relay_mesh(&bare).is_none());
}
/// Malformed input still produces exactly one conforming response.
#[test]
fn malformed_input_is_an_in_band_error() {
assert!(error_of(&respond("not json")).contains("valid JSON"));
assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand"));
}
/// `info` answers without touching a cluster — it is what the desktop
/// calls to render the config form, before any kubeconfig exists.
#[test]
fn info_answers_with_the_protocol_version_and_schema() {
let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap();
assert_eq!(json["ok"], true);
assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION);
assert!(json["config_schema"]["properties"]["namespace"].is_object());
}
}
@@ -0,0 +1,223 @@
//! Identity derivation and the object-naming contract (spec §Pod shape).
//!
//! Every name, label, and annotation below is derived from the pubkey the
//! provider decoded itself from `private_key_nsec` — never from a
//! caller-supplied pubkey (§Deploy State Machine step 0).
use nostr::nips::nip19::FromBech32;
/// `app.kubernetes.io/managed-by` value: the management marker's identity half.
pub const MANAGED_BY: &str = "buzz-backend-kubernetes";
/// Label key carrying [`MANAGED_BY`].
pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
/// Label key carrying [`BINDING_VERSION`] — the marker's schema half.
pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version";
/// Schema version of the object layout this provider writes. Bumped when the
/// pod/Secret shape changes in a way a older provider would mis-handle.
pub const BINDING_VERSION: &str = "1";
/// Label key: truncated pubkey, the reconciliation and GC selector.
pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey";
/// Annotation key: full pubkey. Load-bearing — the truncated label is
/// collision-*resistant*, this is what makes it safe (§Deploy State Machine
/// step 1).
pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full";
/// Annotation key: the recorded create-intent fingerprint.
pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent";
/// Annotation key: the image reference this generation actually resolved to,
/// for post-hoc attribution (§Image).
pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image";
/// An agent identity the provider derived itself, plus every name it implies.
///
/// Constructing this type is the *only* way to obtain the names — so a
/// caller-supplied pubkey cannot reach a selector by any path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pubkey_hex: String,
}
impl AgentIdentity {
/// Derive from the payload's `private_key_nsec`.
///
/// Accepts bech32 `nsec1…`; a malformed or undecodable key is an
/// immediate error, before any substrate read or mutation
/// (§Deploy State Machine step 0).
pub fn from_nsec(nsec: &str) -> Result<Self, String> {
let secret = nostr::SecretKey::from_bech32(nsec.trim())
.map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?;
let keys = nostr::Keys::new(secret);
Ok(Self {
pubkey_hex: keys.public_key().to_hex(),
})
}
/// Full 64-hex public key — the annotation value and the comparison
/// operand for candidate authentication.
pub fn pubkey_hex(&self) -> &str {
&self.pubkey_hex
}
/// Selector label value: first 32 hex chars (128 bits). A full hex pubkey
/// is 64 chars and label values cap at 63, which is why this is truncated
/// and why the annotation check is normative rather than decorative.
pub fn label_pubkey(&self) -> &str {
&self.pubkey_hex[..32]
}
/// Deterministic pod name, also the returned `agent_id`.
pub fn pod_name(&self) -> String {
format!("buzz-agent-{}", &self.pubkey_hex[..12])
}
/// Per-attempt Secret name. `generation` is a fresh random token per
/// create attempt — never reused — which is what makes payload and Secret
/// atomic at the pod-spec boundary (§K8s Secrets).
pub fn secret_name(&self, generation: &str) -> String {
format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation)
}
/// Label selector matching this identity's objects *and* our management
/// marker. Selecting on the marker as well as the identity means an
/// unmarked look-alike never even enters the candidate list.
pub fn selector(&self) -> String {
format!(
"{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}",
self.label_pubkey()
)
}
/// The label set stamped on every object this provider creates.
pub fn labels(&self) -> std::collections::BTreeMap<String, String> {
[
(
LABEL_AGENT_PUBKEY.to_string(),
self.label_pubkey().to_string(),
),
(LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()),
(
LABEL_BINDING_VERSION.to_string(),
BINDING_VERSION.to_string(),
),
]
.into_iter()
.collect()
}
}
/// A fresh generation token: 8 lowercase hex chars from the OS RNG.
///
/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the
/// Secret generation and the harness's lifecycle-frame correlator are one
/// identity (§Launch data tier 3).
pub fn new_generation() -> String {
use rand::RngExt;
let n: u32 = rand::rng().random();
format!("{n:08x}")
}
#[cfg(test)]
mod tests {
use super::*;
/// A fixed test key. Deriving the pubkey (rather than hardcoding both
/// halves) is the point: the test exercises the same derivation the
/// reconciler depends on.
fn identity() -> AgentIdentity {
let keys = nostr::Keys::generate();
let nsec = {
use nostr::nips::nip19::ToBech32;
keys.secret_key().to_bech32().unwrap()
};
let id = AgentIdentity::from_nsec(&nsec).unwrap();
assert_eq!(id.pubkey_hex(), keys.public_key().to_hex());
id
}
#[test]
fn rejects_malformed_nsec() {
for bad in ["", "nsec1", "not-a-key", "npub1abc"] {
assert!(
AgentIdentity::from_nsec(bad).is_err(),
"accepted malformed key {bad:?}"
);
}
}
#[test]
fn tolerates_surrounding_whitespace() {
let keys = nostr::Keys::generate();
use nostr::nips::nip19::ToBech32;
let nsec = keys.secret_key().to_bech32().unwrap();
let padded = format!(" {nsec}\n");
assert_eq!(
AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(),
keys.public_key().to_hex()
);
}
/// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64,
/// one over. That one-char overflow is the whole reason the selector is
/// truncated, so it gets an explicit test.
#[test]
fn label_value_fits_kubernetes_limit() {
let id = identity();
assert_eq!(id.pubkey_hex().len(), 64);
assert_eq!(id.label_pubkey().len(), 32);
assert!(id.label_pubkey().len() <= 63);
}
#[test]
fn pod_name_is_deterministic_and_dns_safe() {
let id = identity();
assert_eq!(id.pod_name(), id.pod_name());
assert_eq!(
id.pod_name(),
format!("buzz-agent-{}", &id.pubkey_hex()[..12])
);
assert!(id.pod_name().len() <= 253);
assert!(id
.pod_name()
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
/// Two attempts must never share a Secret name — that uniqueness is what
/// stops a losing contender from overwriting the winner's identity.
#[test]
fn secret_names_are_per_attempt() {
let id = identity();
let a = id.secret_name(&new_generation());
let b = id.secret_name(&new_generation());
assert_ne!(a, b);
assert!(a.starts_with(&id.pod_name()));
assert!(a.len() <= 253);
}
#[test]
fn selector_requires_the_management_marker() {
let id = identity();
let sel = id.selector();
assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey())));
assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}")));
}
#[test]
fn every_created_object_carries_the_marker() {
let labels = identity().labels();
assert_eq!(
labels.get(LABEL_MANAGED_BY).map(String::as_str),
Some(MANAGED_BY)
);
assert_eq!(
labels.get(LABEL_BINDING_VERSION).map(String::as_str),
Some(BINDING_VERSION)
);
}
}
@@ -0,0 +1,592 @@
//! Decoding API objects into verified observations (spec §Deploy State
//! Machine step 1).
//!
//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the
//! conformance tests drive the *shipped* decoder with real API types rather
//! than a test-only stand-in, and it keeps `classify.rs` free of API types.
//!
//! Verification is the gate, not a filter: [`verify`] returns `None` for any
//! object whose full-pubkey annotation does not equal the derived pubkey or
//! that lacks the management marker, so an unverified object cannot reach
//! classification, deletion, or the returned `agent_id`.
use crate::classify::{Fence, PullFailure, Startup, VerifiedPod};
use crate::intent::Fingerprint;
use crate::naming::{
AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION,
LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY,
};
use k8s_openapi::api::core::v1::{Pod, Secret};
/// Container name the provider creates; status is read from this container.
pub const CONTAINER_NAME: &str = "agent";
/// The startup state, or a state that cannot be settled without one more read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartupObservation {
Resolved(Startup),
/// `CreateContainerConfigError` — recoverable *unless* the referenced
/// Secret is confirmed absent by a most-recent read. The kubelet's reason
/// string is a hint; the provider verifies before treating it as fatal
/// (§Deploy State Machine: "provably" means a verified absence, never a
/// reason string).
ConfigErrorPendingSecretCheck {
secret_name: String,
},
}
/// Does this object carry the management marker (§Pod shape)?
///
/// Identity labels prove identity; the marker asserts protocol ownership.
/// Without it an object that merely matches our schema fails closed.
fn has_marker(labels: Option<&std::collections::BTreeMap<String, String>>) -> bool {
let Some(labels) = labels else { return false };
labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY)
&& labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION)
}
/// Does the full-pubkey annotation equal the derived pubkey?
///
/// The 32-hex label is collision-*resistant*, not collision-free, which is
/// why this check is normative rather than decorative (`:1152-1166`).
fn annotation_matches(
annotations: Option<&std::collections::BTreeMap<String, String>>,
identity: &AgentIdentity,
) -> bool {
annotations
.and_then(|a| a.get(ANNOTATION_PUBKEY_FULL))
.map(|v| v == identity.pubkey_hex())
.unwrap_or(false)
}
/// Is this Secret ours and this identity's? The same fence GC applies before
/// deleting anything.
pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool {
has_marker(secret.metadata.labels.as_ref())
&& annotation_matches(secret.metadata.annotations.as_ref(), identity)
}
/// Decode a pod's startup state from its status.
///
/// "Started" means `state.running` on our container — not pod phase. A pod can
/// sit in phase `Running` with a container that never started, and a pod being
/// gracefully deleted stays in phase `Running` for its whole grace period.
pub fn decode_startup(pod: &Pod) -> StartupObservation {
use StartupObservation::Resolved;
let status = pod.status.as_ref();
let phase = status.and_then(|s| s.phase.as_deref());
let container = status
.and_then(|s| s.container_statuses.as_ref())
.and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME));
if let Some(state) = container.and_then(|c| c.state.as_ref()) {
if state.running.is_some() {
return Resolved(Startup::Started);
}
if state.terminated.is_some() {
return Resolved(Startup::Terminated);
}
if let Some(waiting) = state.waiting.as_ref() {
let reason = waiting.reason.as_deref().unwrap_or_default();
let message = waiting.message.as_deref().unwrap_or_default();
return match reason {
// Structurally invalid reference: no retry can fix it.
"InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken),
"ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) {
Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)),
None => Resolved(Startup::NeverStartedRecoverable),
},
"CreateContainerConfigError" => match referenced_secret(pod) {
Some(secret_name) => {
StartupObservation::ConfigErrorPendingSecretCheck { secret_name }
}
None => Resolved(Startup::NeverStartedRecoverable),
},
_ => Resolved(Startup::NeverStartedRecoverable),
};
}
}
// No container status yet (unscheduled, image pulling before the kubelet
// reports, quota-blocked). A terminal phase without container status still
// means the pod is done.
match phase {
Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated),
_ => Resolved(Startup::NeverStartedRecoverable),
}
}
/// The Secret name this pod's `envFrom` references, if any.
pub fn referenced_secret(pod: &Pod) -> Option<String> {
pod.spec
.as_ref()?
.containers
.iter()
.flat_map(|c| c.env_from.iter().flatten())
.find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone()))
}
/// Classify a pull failure from the kubelet's message.
///
/// Reporting only — [`PullFailure`] is structurally excluded from
/// `Action::Delete`, so a wrong guess here can delay a report but can never
/// destroy anything. `None` means "no permanent cause recognized", which
/// leaves the pod on the ordinary observational path.
fn classify_pull_failure(message: &str) -> Option<PullFailure> {
let m = message.to_ascii_lowercase();
if m.contains("401")
|| m.contains("unauthorized")
|| m.contains("403")
|| m.contains("denied")
|| m.contains("authentication required")
{
return Some(PullFailure::Unauthorized);
}
if m.contains("manifest unknown")
|| m.contains("not found")
|| m.contains("manifest_unknown")
|| m.contains("repository does not exist")
{
return Some(PullFailure::ManifestUnknown);
}
if m.contains("no match for platform") || m.contains("no matching manifest") {
return Some(PullFailure::ArchMismatch);
}
None
}
/// The redacted, actionable condition text for a pull failure.
///
/// Names the registry and the immutable reference — never credentials, and
/// never the kubelet's raw message, which can echo a registry token.
pub fn pull_failure_message(failure: PullFailure, image: &str) -> String {
let registry = image.split('/').next().unwrap_or(image);
match failure {
PullFailure::Unauthorized => format!(
"the cluster is not authorized to pull {image} from {registry}. \
This pull retries indefinitely and will not succeed on its own: \
grant the cluster's nodes access to that registry."
),
PullFailure::ManifestUnknown => {
format!("{registry} has no image at {image}. Check the digest and repository.")
}
PullFailure::ArchMismatch => format!(
"{image} has no variant for the architecture of the nodes it was \
scheduled on."
),
}
}
/// The latest actionable condition for a pod that has not started, redacted.
///
/// Two sources, deliberately treated differently:
///
/// * The container's waiting **reason** is included; its **message** is not.
/// Waiting messages are kubelet-composed and echo the thing that failed —
/// for a pull that is the registry request, which can carry credential
/// material. The reason token alone (`ImagePullBackOff`,
/// `CreateContainerConfigError`) is the diagnostic; the message adds
/// exposure, not information the user can act on.
/// * Pod-condition messages **are** included. They are scheduler- and
/// kubelet-composed from the pod's own spec and cluster capacity
/// ("0/3 nodes are available: Insufficient memory"), which is precisely the
/// actionable half and contains nothing derived from Secret data.
pub fn condition(pod: &Pod) -> Option<String> {
let status = pod.status.as_ref()?;
if let Some(state) = status
.container_statuses
.as_ref()
.and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME))
.and_then(|c| c.state.as_ref())
{
if let Some(waiting) = state.waiting.as_ref() {
if let Some(reason) = waiting.reason.as_deref() {
return Some(format!("the container is waiting, reason {reason}"));
}
}
// Exit code and reason only — the terminated `message` is
// process-composed output and falls under the same redaction rule as
// waiting messages.
if let Some(terminated) = state.terminated.as_ref() {
return Some(match terminated.reason.as_deref() {
Some(reason) => format!(
"the container exited with code {} ({reason})",
terminated.exit_code
),
None => format!("the container exited with code {}", terminated.exit_code),
});
}
}
if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| {
cs.iter().find(|c| c.status == "False").map(|c| {
(
c.type_.clone(),
c.reason.clone().unwrap_or_default(),
c.message.clone().unwrap_or_default(),
)
})
}) {
let detail = [reason, message]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(": ");
return Some(if detail.is_empty() {
format!("pod condition {type_} is false")
} else {
format!("pod condition {type_} is false: {detail}")
});
}
status.phase.as_deref().map(|p| format!("the pod is {p}"))
}
/// Verify a label-selected pod and decode it, or reject it.
///
/// `startup` is supplied by the caller because settling
/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer
/// must not perform.
pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option<VerifiedPod> {
if !has_marker(pod.metadata.labels.as_ref()) {
return None;
}
if !annotation_matches(pod.metadata.annotations.as_ref(), identity) {
return None;
}
Some(VerifiedPod {
name: pod.metadata.name.clone()?,
fence: Fence {
uid: pod.metadata.uid.clone()?,
resource_version: pod.metadata.resource_version.clone()?,
},
deletion_marked: pod.metadata.deletion_timestamp.is_some(),
startup,
recorded_intent: pod
.metadata
.annotations
.as_ref()
.and_then(|a| a.get(ANNOTATION_CREATE_INTENT))
.map(|v| Fingerprint::from_annotation(v)),
})
}
#[cfg(test)]
mod tests {
use super::*;
use k8s_openapi::api::core::v1::{
ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting,
ContainerStatus, PodStatus,
};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
use std::collections::BTreeMap;
fn identity() -> AgentIdentity {
use nostr::nips::nip19::ToBech32;
let keys = nostr::Keys::generate();
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
}
fn base_pod(id: &AgentIdentity) -> Pod {
Pod {
metadata: ObjectMeta {
name: Some(id.pod_name()),
uid: Some("uid-1".into()),
resource_version: Some("100".into()),
labels: Some(id.labels()),
annotations: Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
id.pubkey_hex().to_string(),
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
),
..Default::default()
},
..Default::default()
}
}
fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod {
pod.status = Some(PodStatus {
phase: Some("Running".into()),
container_statuses: Some(vec![ContainerStatus {
name: CONTAINER_NAME.into(),
state: Some(state),
..Default::default()
}]),
..Default::default()
});
pod
}
fn waiting(reason: &str, message: &str) -> ContainerState {
ContainerState {
waiting: Some(ContainerStateWaiting {
reason: Some(reason.into()),
message: Some(message.into()),
}),
..Default::default()
}
}
#[test]
fn running_container_is_started() {
let id = identity();
let pod = with_container_state(
base_pod(&id),
ContainerState {
running: Some(ContainerStateRunning::default()),
..Default::default()
},
);
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::Started)
);
}
/// Pod phase is not the criterion. A pod in phase `Running` whose
/// container never started must NOT read as started, or the reconciler
/// no-ops on a pod that will never serve.
#[test]
fn phase_running_with_waiting_container_is_not_started() {
let id = identity();
let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", ""));
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::NeverStartedRecoverable)
);
}
#[test]
fn terminated_container_is_terminated() {
let id = identity();
let pod = with_container_state(
base_pod(&id),
ContainerState {
terminated: Some(ContainerStateTerminated {
exit_code: 0,
..Default::default()
}),
..Default::default()
},
);
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::Terminated)
);
}
/// A terminal phase with no container status (evicted before the kubelet
/// reported) is still terminated — otherwise the residue is never GC'd.
#[test]
fn terminal_phase_without_container_status_is_terminated() {
let id = identity();
for phase in ["Succeeded", "Failed"] {
let mut pod = base_pod(&id);
pod.status = Some(PodStatus {
phase: Some(phase.into()),
..Default::default()
});
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::Terminated),
"phase {phase}"
);
}
}
#[test]
fn invalid_image_name_is_provably_broken() {
let id = identity();
let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref"));
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::NeverStartedProvablyBroken)
);
}
/// Permanent pull failures are recognized from the message; anything
/// unrecognized stays on the ordinary observational path rather than
/// being guessed at.
#[test]
fn permanent_pull_failures_are_classified() {
let id = identity();
let cases = [
("401 Unauthorized", PullFailure::Unauthorized),
("pull access denied", PullFailure::Unauthorized),
("manifest unknown", PullFailure::ManifestUnknown),
(
"no match for platform in manifest",
PullFailure::ArchMismatch,
),
];
for (message, expected) in cases {
let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message));
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)),
"message {message:?}"
);
}
let pod = with_container_state(
base_pod(&id),
waiting("ImagePullBackOff", "dial tcp: i/o timeout"),
);
assert_eq!(
decode_startup(&pod),
StartupObservation::Resolved(Startup::NeverStartedRecoverable),
"a transient network failure must not be reported as permanent"
);
}
/// The kubelet's reason string is a hint, not proof: a config error defers
/// to a most-recent Secret read before anything is called broken.
#[test]
fn config_error_defers_to_a_secret_read() {
let id = identity();
let mut pod =
with_container_state(base_pod(&id), waiting("CreateContainerConfigError", ""));
pod.spec = Some(k8s_openapi::api::core::v1::PodSpec {
containers: vec![k8s_openapi::api::core::v1::Container {
name: CONTAINER_NAME.into(),
env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource {
secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource {
name: "buzz-agent-abc-gen1".into(),
optional: Some(false),
}),
..Default::default()
}]),
..Default::default()
}],
..Default::default()
});
assert_eq!(
decode_startup(&pod),
StartupObservation::ConfigErrorPendingSecretCheck {
secret_name: "buzz-agent-abc-gen1".into()
}
);
}
/// The auto-repair fence: an object that matches our schema but lacks the
/// marker, or carries someone else's pubkey, is never verified — so it can
/// never be no-op'd against, deleted, or returned as an `agent_id`.
#[test]
fn unmarked_or_mismatched_objects_fail_verification() {
let id = identity();
let other = identity();
let mut unmarked = base_pod(&id);
unmarked.metadata.labels = Some(BTreeMap::new());
assert!(
verify(&unmarked, &id, Startup::Started).is_none(),
"unmarked pod verified"
);
let mut wrong_version = base_pod(&id);
let mut labels = id.labels();
labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string());
wrong_version.metadata.labels = Some(labels);
assert!(verify(&wrong_version, &id, Startup::Started).is_none());
let mut mismatched = base_pod(&id);
mismatched.metadata.annotations = Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
other.pubkey_hex().to_string(),
)]
.into_iter()
.collect(),
);
assert!(
verify(&mismatched, &id, Startup::Started).is_none(),
"collision verified"
);
let mut missing = base_pod(&id);
missing.metadata.annotations = Some(BTreeMap::new());
assert!(verify(&missing, &id, Startup::Started).is_none());
assert!(
verify(&base_pod(&id), &id, Startup::Started).is_some(),
"own pod rejected"
);
}
/// The fence must come from the observed object, and the deletion mark
/// must be read even though the phase says `Running`.
#[test]
fn verified_pod_carries_the_fence_and_deletion_mark() {
let id = identity();
let mut pod = base_pod(&id);
pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now()));
let verified = verify(&pod, &id, Startup::Started).unwrap();
assert_eq!(verified.fence.uid, "uid-1");
assert_eq!(verified.fence.resource_version, "100");
assert!(verified.deletion_marked);
}
/// A pod with no recorded intent reads as `None`, which the classifier
/// groups with divergence.
#[test]
fn missing_intent_annotation_decodes_as_none() {
let id = identity();
assert!(verify(&base_pod(&id), &id, Startup::Started)
.unwrap()
.recorded_intent
.is_none());
}
/// A pull-failure report must name the registry and the immutable
/// reference and nothing else — never the kubelet's raw message, which
/// can echo a registry token.
#[test]
fn pull_failure_messages_are_actionable_and_redacted() {
let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64));
for failure in [
PullFailure::Unauthorized,
PullFailure::ManifestUnknown,
PullFailure::ArchMismatch,
] {
let msg = pull_failure_message(failure, &image);
assert!(msg.contains("ghcr.io"), "{msg}");
assert!(msg.contains(&image), "{msg}");
for secret in ["Bearer", "password", "nsec1", "token"] {
assert!(!msg.contains(secret), "leaked {secret}: {msg}");
}
}
}
#[test]
fn secret_ownership_requires_marker_and_annotation() {
let id = identity();
let other = identity();
let ours = Secret {
metadata: ObjectMeta {
labels: Some(id.labels()),
annotations: Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
id.pubkey_hex().to_string(),
)]
.into(),
),
..Default::default()
},
..Default::default()
};
assert!(secret_is_ours(&ours, &id));
assert!(!secret_is_ours(&ours, &other));
let mut unmarked = ours.clone();
unmarked.metadata.labels = Some(BTreeMap::new());
assert!(!secret_is_ours(&unmarked, &id));
}
}
+446
View File
@@ -0,0 +1,446 @@
//! Pod and Secret construction (spec §Pod shape, §K8s Secrets).
//!
//! The builder is pure: it turns resolved inputs into API objects and performs
//! no I/O, so every normative field is a unit assertion.
use crate::config::{
ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS,
WORKSPACE_PATH,
};
use crate::intent::{Fingerprint, IntentTemplate};
use crate::naming::{
AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL,
};
use k8s_openapi::api::core::v1::{
Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec,
ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume,
VolumeMount,
};
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use std::collections::BTreeMap;
/// Volume name for the agent's writable workspace.
const WORKSPACE_VOLUME: &str = "workspace";
/// The container name. Fixed: log and exec tooling addresses it by name.
const CONTAINER_NAME: &str = "agent";
/// Build the per-attempt Secret holding the resolved environment.
///
/// `immutable: true` — the Secret is written once per attempt and never
/// updated, which is what lets the pod's `envFrom` reference be treated as an
/// atomic binding to this exact payload (§K8s Secrets).
pub fn build_secret(
identity: &AgentIdentity,
namespace: &str,
generation: &str,
env: BTreeMap<String, String>,
) -> Secret {
Secret {
metadata: ObjectMeta {
name: Some(identity.secret_name(generation)),
namespace: Some(namespace.to_string()),
labels: Some(identity.labels()),
annotations: Some(
[(
ANNOTATION_PUBKEY_FULL.to_string(),
identity.pubkey_hex().to_string(),
)]
.into_iter()
.collect(),
),
..Default::default()
},
string_data: Some(env),
immutable: Some(true),
..Default::default()
}
}
/// Build the pod for one create attempt.
///
/// The `fingerprint` is computed from [`intent_template`] over a type that
/// cannot contain the generation or any Secret value, so it is stable across
/// attempts of the same configuration.
pub fn build_pod(
identity: &AgentIdentity,
cfg: &ProviderConfig,
generation: &str,
fingerprint: &Fingerprint,
) -> Pod {
let annotations: BTreeMap<String, String> = [
(
ANNOTATION_PUBKEY_FULL.to_string(),
identity.pubkey_hex().to_string(),
),
(
ANNOTATION_CREATE_INTENT.to_string(),
fingerprint.as_str().to_string(),
),
(ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()),
]
.into_iter()
.collect();
let requests: BTreeMap<String, Quantity> = [
(
"cpu".to_string(),
Quantity(cfg.resources.cpu_request.clone()),
),
(
"memory".to_string(),
Quantity(cfg.resources.memory_request.clone()),
),
]
.into_iter()
.collect();
let limits: BTreeMap<String, Quantity> = [
("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())),
(
"memory".to_string(),
Quantity(cfg.resources.memory_limit.clone()),
),
]
.into_iter()
.collect();
let container = Container {
name: CONTAINER_NAME.to_string(),
image: Some(cfg.image.as_str().to_string()),
// No `command`/`args`: the image's entrypoint execs the harness as
// PID 1 (§Entrypoint). Overriding it here would be how a provider
// accidentally puts a shell in front of the signal receiver.
env_from: Some(vec![EnvFromSource {
secret_ref: Some(SecretEnvSource {
name: identity.secret_name(generation),
optional: Some(false),
}),
..Default::default()
}]),
resources: Some(ResourceRequirements {
requests: Some(requests),
limits: Some(limits),
..Default::default()
}),
volume_mounts: Some(vec![VolumeMount {
name: WORKSPACE_VOLUME.to_string(),
mount_path: WORKSPACE_PATH.to_string(),
..Default::default()
}]),
security_context: Some(SecurityContext {
allow_privilege_escalation: Some(false),
capabilities: Some(Capabilities {
drop: Some(vec!["ALL".to_string()]),
..Default::default()
}),
// `readOnlyRootFilesystem` is deliberately unset: the sprig
// toolchain writes outside the workspace mount (§Pod shape).
..Default::default()
}),
..Default::default()
};
Pod {
metadata: ObjectMeta {
name: Some(identity.pod_name()),
namespace: Some(cfg.namespace.clone()),
labels: Some(identity.labels()),
annotations: Some(annotations),
..Default::default()
},
spec: Some(PodSpec {
containers: vec![container],
restart_policy: Some(RESTART_POLICY.to_string()),
termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS),
// The agent runs prompted, untrusted code while holding an nsec;
// an ambient ServiceAccount token would be an API-stealable
// credential it never needs (§Pod shape hardening). Naming a
// service account selects a scheduling/RBAC identity and MUST NOT
// re-enable token mounting.
automount_service_account_token: Some(false),
service_account_name: cfg.service_account.clone(),
security_context: Some(PodSecurityContext {
run_as_non_root: Some(true),
run_as_user: Some(RUN_AS_UID),
run_as_group: Some(RUN_AS_GID),
fs_group: Some(RUN_AS_GID),
seccomp_profile: Some(SeccompProfile {
type_: "RuntimeDefault".to_string(),
..Default::default()
}),
..Default::default()
}),
volumes: Some(vec![Volume {
name: WORKSPACE_VOLUME.to_string(),
empty_dir: Some(EmptyDirVolumeSource::default()),
..Default::default()
}]),
..Default::default()
}),
..Default::default()
}
}
/// The create-intent template for this configuration (§Deploy State Machine).
pub fn intent_template(
cfg: &ProviderConfig,
env_keys: impl IntoIterator<Item = String>,
) -> IntentTemplate {
IntentTemplate::new(
&cfg.namespace,
&cfg.image,
&cfg.resources,
cfg.service_account.as_deref(),
env_keys,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
fn identity() -> AgentIdentity {
use nostr::nips::nip19::ToBech32;
let keys = nostr::Keys::generate();
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
}
fn provider_config() -> ProviderConfig {
config::parse(&serde_json::json!({
"namespace": "buzz-agents-test",
"image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)),
}))
.unwrap()
}
fn pod() -> Pod {
let cfg = provider_config();
build_pod(
&identity(),
&cfg,
"gen00001",
&intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(),
)
}
fn spec(pod: &Pod) -> &PodSpec {
pod.spec.as_ref().unwrap()
}
/// Every hardening default from §Pod shape, asserted individually so a
/// dropped one names itself.
#[test]
fn hardening_defaults_are_all_present() {
let pod = pod();
let spec = spec(&pod);
assert_eq!(spec.automount_service_account_token, Some(false));
let sc = spec
.security_context
.as_ref()
.expect("pod security context");
assert_eq!(sc.run_as_non_root, Some(true));
assert_eq!(sc.run_as_user, Some(RUN_AS_UID));
assert_ne!(sc.run_as_user, Some(0), "root UID");
assert_eq!(sc.run_as_group, Some(RUN_AS_GID));
assert_eq!(
sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()),
Some("RuntimeDefault")
);
let csc = spec.containers[0]
.security_context
.as_ref()
.expect("container sc");
assert_eq!(csc.allow_privilege_escalation, Some(false));
assert_eq!(
csc.capabilities.as_ref().and_then(|c| c.drop.clone()),
Some(vec!["ALL".to_string()])
);
assert_ne!(csc.privileged, Some(true));
}
/// The forbidden host-namespace and hostPath escapes, asserted as absence.
#[test]
fn never_uses_host_namespaces_or_host_paths() {
let pod = pod();
let spec = spec(&pod);
assert!(spec.host_pid.is_none() || spec.host_pid == Some(false));
assert!(spec.host_network.is_none() || spec.host_network == Some(false));
assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false));
for volume in spec.volumes.as_ref().unwrap() {
assert!(
volume.host_path.is_none(),
"hostPath volume {}",
volume.name
);
}
}
/// `Never` only. `OnFailure` is gated on the harness exit-code contract
/// *and* a crash-loop classification row (`:1121-1139`); the config layer
/// refuses `inactivity_seconds: 0` so this arm is unreachable, and the
/// assertion keeps it that way.
#[test]
fn restart_policy_is_never() {
assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never"));
}
/// 60s, not Kubernetes' default 30s — which would SIGKILL the harness
/// mid-drain and leave presence stale-online (§Pod shape).
#[test]
fn declares_the_sixty_second_grace_budget() {
assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60));
}
/// The pod must not override the image's entrypoint: the image execs the
/// harness as PID 1, and a `command` here is how a shell ends up in front
/// of the signal receiver (§Entrypoint).
#[test]
fn does_not_override_the_image_entrypoint() {
let pod = pod();
let container = &spec(&pod).containers[0];
assert!(container.command.is_none(), "overrode the entrypoint");
assert!(container.args.is_none());
}
#[test]
fn workspace_is_an_emptydir_mounted_at_home() {
let pod = pod();
let spec = spec(&pod);
let volume = &spec.volumes.as_ref().unwrap()[0];
assert!(volume.empty_dir.is_some());
assert!(volume.persistent_volume_claim.is_none());
let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0];
assert_eq!(mount.name, volume.name);
assert_eq!(mount.mount_path, WORKSPACE_PATH);
}
#[test]
fn resources_carry_the_configured_requests_and_limits() {
let mut cfg = provider_config();
cfg.resources.cpu_limit = "4".into();
let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
let r = spec(&pod).containers[0].resources.as_ref().unwrap();
assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into()));
assert_eq!(
r.requests.as_ref().unwrap()["memory"],
Quantity("2Gi".into())
);
assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into()));
assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into()));
}
/// `envFrom` must point at this attempt's Secret and must NOT be optional:
/// an optional reference starts the container with no identity at all,
/// turning a missing-Secret bug into an agent that silently cannot
/// authenticate.
#[test]
fn env_from_references_this_attempts_secret_and_is_required() {
let id = identity();
let cfg = provider_config();
let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f"));
let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0];
let secret_ref = source.secret_ref.as_ref().unwrap();
assert_eq!(secret_ref.name, id.secret_name("gen00042"));
assert_eq!(secret_ref.optional, Some(false));
assert!(source.config_map_ref.is_none());
}
/// Identity, ownership marker, and the recorded intent all travel on the
/// pod — the GC and reconciliation fences read exactly these.
#[test]
fn pod_carries_identity_marker_and_recorded_intent() {
let id = identity();
let cfg = provider_config();
let fp = intent_template(&cfg, ["A".to_string()]).fingerprint();
let pod = build_pod(&id, &cfg, "g", &fp);
let meta = &pod.metadata;
assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str()));
assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test"));
assert_eq!(meta.labels.as_ref().unwrap(), &id.labels());
let ann = meta.annotations.as_ref().unwrap();
assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex());
assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str());
assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str());
}
/// The Secret is immutable and marker-bearing: immutability is what makes
/// the pod's `envFrom` an atomic binding, and the marker is what GC
/// requires before it will delete anything.
#[test]
fn secret_is_immutable_marked_and_holds_the_env() {
let id = identity();
let env: BTreeMap<String, String> =
[("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into();
let secret = build_secret(&id, "ns", "gen1", env.clone());
assert_eq!(secret.immutable, Some(true));
assert_eq!(secret.string_data.as_ref().unwrap(), &env);
assert_eq!(
secret.metadata.name.as_deref(),
Some(id.secret_name("gen1").as_str())
);
assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels());
assert_eq!(
secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL],
id.pubkey_hex()
);
// `data` must stay unset — setting both is an apiserver rejection.
assert!(secret.data.is_none());
}
/// Naming a service account selects a scheduling identity; it must not
/// re-enable token mounting (§Pod shape hardening, `:1221-1225`).
#[test]
fn service_account_does_not_re_enable_token_mounting() {
let mut cfg = provider_config();
cfg.service_account = Some("agent-sa".into());
let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa"));
assert_eq!(spec(&pod).automount_service_account_token, Some(false));
}
/// The fingerprint recorded on the pod is the one the classifier will
/// recompute — pinned end-to-end so a builder change that forgets to feed
/// the template a field cannot pass silently.
#[test]
fn recorded_fingerprint_matches_a_fresh_computation() {
let cfg = provider_config();
let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()];
let fp = intent_template(&cfg, keys.clone()).fingerprint();
let pod = build_pod(&identity(), &cfg, "gen-a", &fp);
let recorded = Fingerprint::from_annotation(
&pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT],
);
assert_eq!(recorded, intent_template(&cfg, keys).fingerprint());
}
/// Two attempts differing only in generation must record the *same*
/// fingerprint, or the divergence discriminator fires on every deploy and
/// the never-started row deletes healthy pending pods.
#[test]
fn generation_does_not_change_the_recorded_fingerprint() {
let cfg = provider_config();
let keys = ["BUZZ_RELAY_URL".to_string()];
let a = intent_template(&cfg, keys.clone()).fingerprint();
let b = intent_template(&cfg, keys).fingerprint();
let id = identity();
let pod_a = build_pod(&id, &cfg, "gen-1", &a);
let pod_b = build_pod(&id, &cfg, "gen-2", &b);
let read =
|p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone();
assert_eq!(read(&pod_a), read(&pod_b));
// ...while the Secret they reference differs.
let secret_of = |p: &Pod| {
spec(p).containers[0].env_from.as_ref().unwrap()[0]
.secret_ref
.as_ref()
.unwrap()
.name
.clone()
};
assert_ne!(secret_of(&pod_a), secret_of(&pod_b));
}
}
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
//! The stdin/stdout JSON protocol (spec §Provider Protocol).
//!
//! One process per operation: one JSON object in, one JSON object out.
//! These types are this provider's view of the contract; the golden fixtures
//! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The wire-contract version this provider speaks (spec §Info).
pub const PROTOCOL_VERSION: u32 = 1;
/// Request envelope. `op` discriminates; unknown ops are an in-band error.
///
/// `request_id` is deliberately absent from every variant. The desktop sends
/// it, but the exchange is one request and one response per process, so there
/// is nothing to correlate and nothing in the response schema to echo it into
/// (`:387`, `:416` — neither response shape carries it). Serde ignores it on
/// the way in, so a caller that sends it is accepted; typing it would only
/// create a field nothing reads.
#[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum Request {
Info,
Deploy(Box<DeployRequest>),
}
#[derive(Debug, Deserialize)]
pub struct DeployRequest {
pub agent: AgentPayload,
#[serde(default)]
pub provider_config: serde_json::Value,
}
/// The agent payload (spec §Deploy).
///
/// Only the fields this binding actually consumes are typed. `name` (display
/// name), `model`, `provider`, and `turn_timeout_seconds` are deliberately
/// absent: object names derive from the pubkey, not the display name
/// (`:1150-1152`), the model/provider pair arrives already resolved inside
/// `launch`, and the timeout is ignored upstream — typing any of them would
/// invite a provider-side remap the spec forbids.
#[derive(Debug, Deserialize)]
pub struct AgentPayload {
pub relay_url: String,
pub private_key_nsec: String,
#[serde(default)]
pub auth_tag: Option<String>,
#[serde(default)]
pub respond_to: Option<String>,
#[serde(default)]
pub respond_to_allowlist: Option<Vec<String>>,
/// User env, already merged global < persona < agent by the desktop and
/// already stripped of reserved keys. Superseded by `launch.env` when
/// `launch` is present — a provider MUST NOT re-merge it on top
/// (§Launch data, precedence tier 2).
#[serde(default)]
pub env_vars: BTreeMap<String, String>,
/// The desktop-resolved launch contract. Absent only from a desktop
/// predating Known Defect 3's fix.
#[serde(default)]
pub launch: Option<LaunchBlock>,
}
/// Desktop-resolved launch data (spec §Launch data).
#[derive(Debug, Default, Deserialize)]
pub struct LaunchBlock {
/// Command *name*, resolved against the image's PATH — never a host path.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
/// Layered env: baked → runtime metadata → definition → global → persona
/// → agent. Precedence tier 2.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Overridable behavior defaults. Precedence tier 1 — user env beats
/// these, matching the local spawn.
#[serde(default)]
pub policy_env: BTreeMap<String, String>,
/// Resolved workspace owner (hex). The respond-to gate's one
/// irreducible input; without it or `auth_tag` the harness cannot match
/// `!shutdown`.
#[serde(default)]
pub owner_pubkey: Option<String>,
}
/// Response envelope. Serialized flat — `{"ok": true, …}` — because the
/// desktop reads `ok`, `error`, and `agent_id` off the top level.
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum Response {
Info(InfoResponse),
Deploy(DeployResponse),
Error(ErrorResponse),
}
#[derive(Debug, Serialize)]
pub struct InfoResponse {
pub ok: bool,
pub name: &'static str,
pub version: &'static str,
pub protocol_version: u32,
pub description: &'static str,
pub config_schema: serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct DeployResponse {
pub ok: bool,
pub agent_id: String,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub ok: bool,
pub error: String,
}
impl Response {
pub fn error(message: impl Into<String>) -> Self {
Response::Error(ErrorResponse {
ok: false,
error: message.into(),
})
}
/// The provider's self-description (spec §Info). Pure — no cluster
/// contact — because the desktop calls it to render the config form
/// before a kubeconfig is known to exist.
pub fn info() -> Self {
Response::Info(InfoResponse {
ok: true,
name: "kubernetes",
version: env!("CARGO_PKG_VERSION"),
protocol_version: PROTOCOL_VERSION,
description: "Runs agents as pods in a Kubernetes cluster",
config_schema: crate::config::config_schema(),
})
}
pub fn deployed(agent_id: impl Into<String>) -> Self {
Response::Deploy(DeployResponse {
ok: true,
agent_id: agent_id.into(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_info_request() {
let r: Request = serde_json::from_str(r#"{"op":"info","request_id":"abc"}"#).unwrap();
assert!(matches!(r, Request::Info));
}
/// The desktop sends `request_id` on every call, but the exchange is 1:1
/// per process — a provider that hard-required it would fail a
/// conforming-but-minimal caller for no safety gain.
#[test]
fn request_id_is_optional() {
let r: Request = serde_json::from_str(r#"{"op":"info"}"#).unwrap();
assert!(matches!(r, Request::Info));
}
#[test]
fn rejects_unknown_op() {
assert!(serde_json::from_str::<Request>(r#"{"op":"undeploy"}"#).is_err());
}
/// Payload fields this binding does not consume must not break parsing:
/// the desktop sends `model`, `provider`, `system_prompt` and more, and a
/// provider that rejected them would break on every real deploy.
#[test]
fn ignores_unconsumed_payload_fields() {
let json = r#"{
"op":"deploy","request_id":"r1",
"agent":{
"name":"a","relay_url":"wss://r","private_key_nsec":"nsec1x",
"model":"gpt-5","provider":"openai","system_prompt":"hi",
"turn_timeout_seconds":30,"parallelism":10,
"agent_command":"goose","agent_args":[]
},
"provider_config":{"namespace":"ns"}
}"#;
let r: Request = serde_json::from_str(json).unwrap();
let Request::Deploy(d) = r else {
panic!("wrong op")
};
assert_eq!(d.agent.relay_url, "wss://r");
assert!(d.agent.launch.is_none());
}
#[test]
fn parses_launch_block() {
let json = r#"{
"op":"deploy",
"agent":{
"relay_url":"wss://r","private_key_nsec":"nsec1x",
"launch":{
"command":"goose","args":["run","--x"],
"env":{"GOOSE_MODEL":"m"},
"policy_env":{"GOOSE_MODE":"auto"},
"owner_pubkey":"deadbeef"
}
}
}"#;
let Request::Deploy(d) = serde_json::from_str::<Request>(json).unwrap() else {
panic!("wrong op")
};
let l = d.agent.launch.unwrap();
assert_eq!(l.command.as_deref(), Some("goose"));
assert_eq!(l.args, ["run", "--x"]);
assert_eq!(l.env["GOOSE_MODEL"], "m");
assert_eq!(l.policy_env["GOOSE_MODE"], "auto");
assert_eq!(l.owner_pubkey.as_deref(), Some("deadbeef"));
}
/// A null `owner_pubkey`/`auth_tag` must parse (the refusal is a policy
/// decision made later, with a specific message), not fail as a type error.
#[test]
fn null_owner_fields_parse() {
let json = r#"{"op":"deploy","agent":{
"relay_url":"wss://r","private_key_nsec":"nsec1x","auth_tag":null,
"launch":{"owner_pubkey":null}
}}"#;
let Request::Deploy(d) = serde_json::from_str::<Request>(json).unwrap() else {
panic!("wrong op")
};
assert!(d.agent.auth_tag.is_none());
assert!(d.agent.launch.unwrap().owner_pubkey.is_none());
}
/// The desktop reads `ok`/`error`/`agent_id` off the top level, so the
/// enum must serialize flat with no variant tag.
#[test]
fn responses_serialize_flat() {
let v = serde_json::to_value(Response::deployed("buzz-agent-abc")).unwrap();
assert_eq!(v["ok"], true);
assert_eq!(v["agent_id"], "buzz-agent-abc");
assert!(v.get("Deploy").is_none());
let v = serde_json::to_value(Response::error("boom")).unwrap();
assert_eq!(v["ok"], false);
assert_eq!(v["error"], "boom");
}
}
@@ -0,0 +1,39 @@
# Provider wire fixtures
The shared arbiter for the stdin/stdout contract between the desktop
(`agents_deploy.rs`) and this provider (spec §Provider Protocol).
Each `*.request.json` is a request the desktop can emit; each matching
`*.response.json` is the exact response this provider produces for it. The
provider side is asserted by `tests/wire_fixtures.rs`; the desktop side should
assert that its emitted payloads parse as the corresponding request.
Three rules keep these useful rather than decorative:
* **Requests are recorded, not invented.** A fixture that no caller emits
tests a contract nobody has. "Recorded" means *executed and transcribed*
`deploy-full-launch.request.json` is the output of the desktop's real
`build_launch_block``deploy_payload_json` path, not a shape derived by
reading those functions. Deriving it is how this fixture acquired four
impossible values at once: a `respond_to` that was a pubkey where the
desktop serializes a kebab-case `RespondTo` enum, allowlist and owner
values failing `validate_respond_to_allowlist`'s 64-hex rule
(`types.rs:897`), an invented `BUZZ_ACP_PARALLELISM` where the emitter
writes `BUZZ_ACP_AGENTS` (`runtime.rs:729`), and a `launch.env` key from
no layer of `resolve_effective_harness_descriptor`.
* **The provider cannot police this file, so the desktop must.** Every field
above is one this provider is deliberately indifferent to — `respond_to` is
an opaque `Option<String>`, the allowlist an opaque `Vec<String>`,
`policy_env` an arbitrary map — so `the_full_desktop_payload_is_accepted`
passes on invented data exactly as happily as on recorded data. The
enforcement is the desktop's whole-object equality test, which *builds* the
payload and compares it to this file. A completeness guard (the case-list
directory scan in `wire_fixtures.rs`) stops a case from going missing; it
cannot tell you a case is false.
* **Responses are byte-compared after key-sorted re-serialization**, so a
field rename or a type change fails here rather than in a desktop that
silently reads `undefined`.
`deploy-*` fixtures cover only responses reachable without a cluster —
refusals and malformed input. A successful deploy needs an apiserver and is
covered by the conformance suite, not by a static fixture.
@@ -0,0 +1,53 @@
{
"op": "deploy",
"request_id": "req-6",
"agent": {
"agent_args": [],
"agent_command": "goose",
"auth_tag": "tag-1",
"env_vars": {
"USER_KEY": "user-value"
},
"idle_timeout_seconds": null,
"launch": {
"args": [
"acp"
],
"command": "goose",
"env": {
"GOOSE_MODEL": "gpt-5",
"GOOSE_PROVIDER": "openai",
"USER_KEY": "user-value"
},
"owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"policy_env": {
"BUZZ_ACP_AGENTS": "10",
"BUZZ_ACP_DISPLAY_NAME": "worker",
"BUZZ_ACP_LAZY_POOL": "true",
"BUZZ_ACP_MODEL": "gpt-5",
"BUZZ_ACP_RELAY_OBSERVER": "true",
"BUZZ_ACP_SESSION_TITLE": "worker",
"GOOSE_MODE": "auto"
}
},
"max_turn_duration_seconds": null,
"model": "gpt-5",
"name": "worker",
"parallelism": 10,
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
"provider": "openai",
"relay_url": "wss://relay.example",
"respond_to": "allowlist",
"respond_to_allowlist": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
],
"system_prompt": null,
"turn_timeout_seconds": 300
},
"provider_config": {
"namespace": "buzz-agents-test",
"image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"inactivity_seconds": 3600
}
}
@@ -0,0 +1,9 @@
{
"op": "deploy",
"request_id": "req-5",
"agent": {
"relay_url": "wss://relay.example",
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"
},
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
}
@@ -0,0 +1 @@
{"ok":false,"error":"deploy refused: neither auth_tag nor launch.owner_pubkey resolved — without an owner the agent cannot honor !shutdown"}
@@ -0,0 +1,11 @@
{
"op": "deploy",
"request_id": "req-3",
"agent": {
"relay_url": "wss://relay.example",
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
"auth_tag": "tag-1",
"provider": " relay-mesh "
},
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
}
@@ -0,0 +1 @@
{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."}
@@ -0,0 +1,12 @@
{
"op": "deploy",
"request_id": "req-2",
"agent": {
"name": "mesh-agent",
"relay_url": "wss://relay.example",
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
"auth_tag": "tag-1",
"provider": "relay-mesh"
},
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
}
@@ -0,0 +1 @@
{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."}
@@ -0,0 +1,10 @@
{
"op": "deploy",
"request_id": "req-4",
"agent": {
"relay_url": "wss://relay.example",
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
"auth_tag": "tag-1"
},
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig:latest"}
}
@@ -0,0 +1 @@
{"ok":false,"error":"provider_config.image \"ghcr.io/block/buzz-sprig:latest\" is not digest-pinned: a tag is a mutable pointer, and this object runs with the agent's private key. Use name@sha256:<64 hex chars>"}
@@ -0,0 +1 @@
{"op":"info","request_id":"req-1"}
@@ -0,0 +1,222 @@
//! Golden wire fixtures (spec §Provider Protocol).
//!
//! These drive the **built binary** over a real pipe rather than calling an
//! in-process function: the contract the desktop depends on is
//! `stdin → one JSON object on stdout → exit code`, and an in-process test
//! would assert the shape of a value while skipping the three things that
//! actually break — the process writing nothing, writing two objects, or
//! signalling the outcome through the exit code.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-wire")
}
/// Feed one request to the binary; return `(stdout, exit code)`.
fn run(request: &str) -> (String, i32) {
let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-backend-kubernetes"))
// A kubeconfig that does not exist, so a fixture that accidentally
// reaches the cluster fails loudly here instead of depending on
// whatever cluster the developer is pointed at.
.env("KUBECONFIG", "/nonexistent/kubeconfig-for-fixture-tests")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("could not run the provider binary");
child
.stdin
.take()
.expect("no stdin")
.write_all(request.as_bytes())
.expect("could not write the request");
let out = child.wait_with_output().expect("provider did not exit");
(
String::from_utf8(out.stdout).expect("stdout was not UTF-8"),
out.status.code().unwrap_or(-1),
)
}
fn read(name: &str) -> String {
std::fs::read_to_string(fixtures().join(name))
.unwrap_or_else(|e| panic!("could not read fixture {name}: {e}"))
}
/// Every response fixture, byte-compared after key-sorted re-serialization so
/// a field rename fails here rather than in a desktop reading `undefined`.
#[test]
fn responses_match_their_fixtures() {
let cases = [
"deploy-relay-mesh",
"deploy-relay-mesh-padded",
"deploy-tag-image",
"deploy-no-owner",
];
// The list must cover every response fixture on disk. A literal array is
// never empty, so `!is_empty()` would assert nothing; what can actually go
// wrong is a fixture added to the directory and never added here, which
// reads as a passing suite that exercises one case fewer than it appears to.
let mut on_disk: Vec<String> = std::fs::read_dir(fixtures())
.expect("could not read the fixture directory")
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.filter_map(|name| Some(name.strip_suffix(".response.json")?.to_string()))
.collect();
on_disk.sort();
let mut listed: Vec<String> = cases.iter().map(|c| c.to_string()).collect();
listed.sort();
assert_eq!(on_disk, listed, "response fixtures and cases disagree");
for case in cases {
let (stdout, code) = run(&read(&format!("{case}.request.json")));
assert_eq!(code, 0, "{case}: a produced response must exit 0");
// Exactly one object, terminated by exactly one newline. Two responses
// would leave the desktop's reader holding a second one forever.
assert_eq!(
stdout.matches('\n').count(),
1,
"{case}: expected exactly one line, got {stdout:?}"
);
let actual: serde_json::Value =
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{case}: {e}: {stdout:?}"));
let expected: serde_json::Value =
serde_json::from_str(&read(&format!("{case}.response.json"))).unwrap();
assert_eq!(
actual, expected,
"{case}: response drifted from its fixture"
);
}
}
/// `info` is checked on the fields the desktop reads rather than byte-for-byte:
/// the namespace default is randomly generated per call (§K8s Namespace), so a
/// golden copy of it would be a test that fails every run.
#[test]
fn info_response_carries_the_contract_fields() {
let (stdout, code) = run(&read("info.request.json"));
assert_eq!(code, 0);
let info: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["ok"], true);
assert_eq!(info["protocol_version"], 1);
assert_eq!(info["name"], "kubernetes");
let schema = &info["config_schema"];
assert_eq!(
schema["required"],
serde_json::json!(["namespace", "image"])
);
let default = schema["properties"]["namespace"]["default"]
.as_str()
.expect("no generated namespace default");
assert!(
default.starts_with("buzz-agents-"),
"unexpected namespace default: {default}"
);
let image_default = schema["properties"]["image"]["default"]
.as_str()
.expect("no image default");
assert!(
image_default.starts_with("ghcr.io/block/buzz-sprig:")
&& image_default.contains("@sha256:"),
"unexpected image default: {image_default}"
);
}
/// The desktop's richest payload must parse. No response fixture: this one
/// reaches the cluster, so its outcome depends on a kubeconfig. What it
/// guards is that every field the desktop sends is *accepted* — a payload the
/// provider rejects at parse time is a deploy that never starts.
#[test]
fn the_full_desktop_payload_is_accepted() {
let (stdout, code) = run(&read("deploy-full-launch.request.json"));
assert_eq!(code, 0);
let response: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let error = response["error"].as_str().unwrap_or_default();
// It fails — there is no cluster — but it must fail at the *connection*,
// having accepted every field above it.
assert!(
error.contains("kubeconfig"),
"the full payload was rejected before reaching the cluster: {error}"
);
}
/// Sami's pre-registered respond-to matrix, driven through the built binary.
///
/// `build_env` runs at `main.rs:124`, `client::connect` at `:132`, so under a
/// kubeconfig that cannot exist the error string *is* the ordering assertion:
/// "kubeconfig" means the gate passed and we reached the cluster, anything
/// else means we refused before writing a Secret. A test asserting only
/// `ok: false` would pass on the connection error and prove nothing.
///
/// The cases are applied to the real full-launch request so each one differs
/// from a known-good deploy in exactly the field under test.
#[test]
fn the_respond_to_gate_matches_the_harness_acceptance_surface() {
let key_a = "a".repeat(64);
let padded_upper = format!(" {} ", "A".repeat(64));
// (name, respond_to, allowlist, must reach the cluster)
let cases: Vec<(&str, &str, Option<Vec<String>>, bool)> = vec![
("allowlist + []", "allowlist", Some(vec![]), false),
("allowlist + absent", "allowlist", None, false),
(
"allowlist + junk",
"allowlist",
Some(vec!["beefcafe".into()]),
false,
),
("unparseable mode", "npub1abc", None, false),
("padded mode", " allowlist ", None, false),
(
"allowlist + two valid",
"allowlist",
Some(vec![key_a.clone(), "b".repeat(64)]),
true,
),
(
"owner-only + junk list",
"owner-only",
Some(vec!["beefcafe".into()]),
true,
),
(
"allowlist + padded upper",
"allowlist",
Some(vec![padded_upper]),
true,
),
("nobody", "nobody", None, true),
("anyone", "anyone", None, true),
];
let base: serde_json::Value =
serde_json::from_str(&read("deploy-full-launch.request.json")).unwrap();
for (name, mode, allowlist, reaches_cluster) in cases {
let mut request = base.clone();
let agent = &mut request["agent"];
agent["respond_to"] = serde_json::json!(mode);
agent["respond_to_allowlist"] = match &allowlist {
Some(list) => serde_json::json!(list),
None => serde_json::Value::Null,
};
let (stdout, code) = run(&request.to_string());
assert_eq!(code, 0, "{name}: provider did not exit cleanly");
let response: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let error = response["error"].as_str().unwrap_or_default();
let reached = error.contains("kubeconfig");
assert_eq!(
reached, reaches_cluster,
"{name}: expected reaches_cluster={reaches_cluster}, got error: {error}"
);
if !reaches_cluster {
assert!(
error.contains("deploy refused"),
"{name}: refused, but not by the gate: {error}"
);
}
}
}