diff --git a/docs/README.md b/docs/README.md index ad711a2e..1acbf4dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ New to NEEDLE? Start with these guides to get up and running quickly. | **[Quickstart Example](examples/quickstart/README.md)** | End-to-end walkthrough from empty workspace to first closed bead | | **[Quickstart Expected Output](examples/quickstart/expected-output.md)** | Reference output for the quickstart example | | **[Configuration Guide](configuration.md)** | Complete configuration reference (config.yaml, environment variables, CLI args) | +| **[External Bead Backends](external-bead-backends.md)** | Install and explicitly bind an operator-owned CLI descriptor | | **[Agent Adapter Authoring](plan/plan.md#agent-adapters)** | How to write custom agent adapters (invoke_template schema) | | **[Plugin: Claude Interactive](templates/AGENTS-needle.md)** | NEEDLE workspace template for Claude Code sessions | diff --git a/docs/external-bead-backends.md b/docs/external-bead-backends.md new file mode 100644 index 00000000..be0b2b4d --- /dev/null +++ b/docs/external-bead-backends.md @@ -0,0 +1,82 @@ +# External bead backends + +NEEDLE can bind a workspace to an operator-installed bead CLI descriptor. The +workspace selects the descriptor by name; it cannot choose the descriptor +directory or substitute command templates. + +## Install and select a descriptor + +Place one YAML descriptor in: + +```text +~/.config/needle/bead-backends/.yaml +``` + +Then select its `name` in the workspace's committed `.needle.yaml`: + +```yaml +bead_cli: + backend: example-remote +``` + +An operator may set `bead_cli.path` to an explicit executable path. Otherwise +NEEDLE resolves the descriptor's `binary` on `PATH`, then its `detect_paths`. +The descriptor and resolved executable become one immutable runtime binding. +Changing `bead_cli.backend`, `bead_cli.path`, or a descriptor requires a worker +restart; a running worker does not silently switch stores. + +Descriptors are trusted operator configuration. Do not install descriptors +from a repository under work or construct their operations from work-item +content. Two operator files with the same descriptor name are rejected as +ambiguous. A user file may intentionally replace a shipped built-in by name. + +## Contract + +External descriptors currently implement the same complete operation set as a +shipped backend. Validation rejects missing operations, unsupported strategy +names, invalid regular expressions, and unresolvable placeholders before any +store command runs. + +NEEDLE executes `version_command` first, with a five-second timeout and bounded +stdout/stderr. Its output must match `identity_pattern`. A failed, timed-out, or +mismatched identity check prevents the store from opening. Native bead-rs +capability probing remains specific to the `bead-rs` descriptor. + +Use `strategy: atomic_command` for a descriptor whose `claim` operation performs +one atomic claim. The command returns one JSON object: + +```json +{"outcome":"claimed","bead_id":"work-123"} +{"outcome":"race_lost","claimed_by":"worker-b"} +{"outcome":"not_claimable","reason":"paused"} +{"outcome":"error","reason":"store unavailable"} +``` + +Malformed JSON, unknown outcomes, command failures, and missing required fields +are errors. They are never interpreted as an empty queue. Arguments are passed +as an argv vector, so IDs and actor names containing spaces or punctuation are +not shell-split. + +Prompt fragments and canary lookups render from the same resolved descriptor. +The worker, supervisor, doctor, validation commands, and cross-workspace lookup +all use explicit workspace bindings rather than rediscovering a CLI. + +## Local example + +The hermetic fixture is executable without credentials or network access: + +```sh +cargo test --test external_backend_runtime +``` + +It creates a temporary operator descriptor directory and fake CLI, selects the +external backend through normal configuration, verifies identity before store +mutation, exercises descriptor-rendered claim/release commands, and races two +claims to prove exactly one winner. + +## Current boundary + +This extension drives CLI-shaped stores. It does not yet add opaque remote lease +context, renewal, fenced terminal mutations, or capability-aware subsets of the +operation contract. Those require the separate remote-lifecycle change; an +external descriptor must not emulate them by pretending to be bead-rs. diff --git a/src/bead_store/backend.rs b/src/bead_store/backend.rs index 11910646..4d8be241 100644 --- a/src/bead_store/backend.rs +++ b/src/bead_store/backend.rs @@ -134,6 +134,15 @@ pub struct BeadBackend { pub error_markers: BeadBackendErrorMarkers, } +/// One validated descriptor together with the operator-controlled source that +/// supplied it. Runtime consumers retain this provenance instead of +/// rediscovering a similarly named descriptor later. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedBeadBackend { + pub descriptor: BeadBackend, + pub source: PathBuf, +} + fn default_version_command() -> Vec { vec!["--version".to_string()] } @@ -409,16 +418,35 @@ fn allowed_placeholders(operation: &str) -> &'static [&'static str] { } } -/// Load built-ins plus user YAML descriptors, overriding built-ins by name. +/// Load built-ins plus user YAML descriptors. A user descriptor may override a +/// built-in by name, but two operator files defining the same name are +/// rejected as ambiguous. pub fn load_bead_backends( dir: &Path, built_ins: &[BeadBackend], ) -> Result> { + Ok(load_bead_backends_with_sources(dir, built_ins)? + .into_iter() + .map(|(name, loaded)| (name, loaded.descriptor)) + .collect()) +} + +/// Load and validate backend descriptors while preserving their source. +pub fn load_bead_backends_with_sources( + dir: &Path, + built_ins: &[BeadBackend], +) -> Result> { let mut backends = HashMap::new(); for backend in built_ins { let source = PathBuf::from(format!("", backend.name)); backend.validate(&source)?; - backends.insert(backend.name.clone(), backend.clone()); + backends.insert( + backend.name.clone(), + LoadedBeadBackend { + descriptor: backend.clone(), + source, + }, + ); } if !dir.exists() { @@ -449,11 +477,33 @@ pub fn load_bead_backends( let backend: BeadBackend = serde_yaml::from_str(&text) .with_context(|| format!("invalid YAML in bead backend file: {}", path.display()))?; backend.validate(&path)?; - backends.insert(backend.name.clone(), backend); + if let Some(previous) = backends.get(&backend.name) { + if !is_builtin_source(&previous.source) { + bail!( + "ambiguous bead backend descriptor '{}': defined by both {} and {}", + backend.name, + previous.source.display(), + path.display() + ); + } + } + backends.insert( + backend.name.clone(), + LoadedBeadBackend { + descriptor: backend, + source: path, + }, + ); } Ok(backends) } +fn is_builtin_source(source: &Path) -> bool { + source + .to_str() + .is_some_and(|value| value.starts_with("')) +} + /// Shipped descriptors. User files can replace this descriptor by name. pub fn builtin_bead_backends() -> Vec { vec![builtin_bead_rs()] diff --git a/src/bead_store/cli_store.rs b/src/bead_store/cli_store.rs index c1b366eb..4b2e51dc 100644 --- a/src/bead_store/cli_store.rs +++ b/src/bead_store/cli_store.rs @@ -16,8 +16,8 @@ use crate::types::{Bead, BeadId, BeadStatus, ClaimResult}; use super::{ execute_create_id_strategy, execute_labels_strategy, spawn_with_etxtbsy_retry_child, - validate_strategy_name, BeadBackend, BeadOperationSpec, BeadStore, ClaimStrategy, Filters, - NewChild, ParseShape, ParsedStrategy, RepairReport, + validate_strategy_name, BeadBackend, BeadOperationSpec, BeadPromptCommands, BeadStore, + ClaimStrategy, Filters, NewChild, ParseShape, ParsedStrategy, RepairReport, }; const DEFAULT_TIMEOUT_SECS: u64 = 30; @@ -323,19 +323,77 @@ impl CliBeadStore { async fn claim_auto_inner(&self, actor: &str) -> Result { let values = HashMap::from([("actor", actor.to_string())]); - let stdout = self.run_operation("claim_auto", &values).await?; + self.claim_from_operation("claim_auto", &values).await + } + + async fn claim_from_operation( + &self, + operation: &str, + values: &HashMap<&str, String>, + ) -> Result { + let stdout = self.run_operation(operation, values).await?; let json: serde_json::Value = serde_json::from_str(&stdout) - .with_context(|| format!("{} claim returned invalid JSON", self.backend.name))?; + .with_context(|| format!("{} {operation} returned invalid JSON", self.backend.name))?; let bead_id = json .get("bead_id") .or_else(|| json.pointer("/data/bead_id")) .and_then(serde_json::Value::as_str) .filter(|id| !id.is_empty()); - match bead_id { - Some(id) => Ok(ClaimResult::Claimed(self.show(&BeadId::from(id)).await?)), - None => Ok(ClaimResult::NotClaimable { - reason: "no beads available".to_string(), + match json.get("outcome").and_then(serde_json::Value::as_str) { + Some("claimed") => { + let id = bead_id.ok_or_else(|| { + anyhow::anyhow!( + "{} {operation} claimed response omitted bead_id", + self.backend.name + ) + })?; + Ok(ClaimResult::Claimed(self.show(&BeadId::from(id)).await?)) + } + Some("race_lost") => { + let claimed_by = json + .get("claimed_by") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "{} {operation} race_lost response omitted claimed_by", + self.backend.name + ) + })?; + Ok(ClaimResult::RaceLost { + claimed_by: claimed_by.to_string(), + }) + } + Some("not_claimable") => Ok(ClaimResult::NotClaimable { + reason: json + .get("reason") + .and_then(serde_json::Value::as_str) + .unwrap_or("no work is currently claimable") + .to_string(), + }), + Some("error") => Ok(ClaimResult::ClaimError { + reason: json + .get("reason") + .and_then(serde_json::Value::as_str) + .unwrap_or("backend rejected claim") + .to_string(), }), + Some(other) => bail!( + "{} {operation} returned unknown claim outcome '{other}'", + self.backend.name + ), + None if bead_id.is_some() => Ok(ClaimResult::Claimed( + self.show(&BeadId::from(bead_id.unwrap())).await?, + )), + None if json.get("bead_id").is_some() || json.pointer("/data/bead_id").is_some() => { + Ok(ClaimResult::NotClaimable { + reason: "no beads available".to_string(), + }) + } + None => bail!( + "{} {operation} response omitted normalized outcome", + self.backend.name + ), } } @@ -399,6 +457,22 @@ impl CliBeadStore { #[async_trait] impl BeadStore for CliBeadStore { + fn prompt_commands(&self) -> Option { + let values = HashMap::from([ + ("blocked", "".to_string()), + ("blocker", "".to_string()), + ]); + let dep_add = self.render_operation("dep_add", &values).ok()?; + let cli = shell_quote(&self.binary.display().to_string()); + Some(BeadPromptCommands { + dep_add: std::iter::once(cli.clone()) + .chain(dep_add) + .collect::>() + .join(" "), + cli, + }) + } + fn is_corruption_error(&self, message: &str) -> bool { self.backend .error_contains_any(message, &self.backend.error_markers.corruption) @@ -612,11 +686,16 @@ impl BeadStore for CliBeadStore { } async fn claim(&self, id: &BeadId, actor: &str) -> Result { - if matches!( - self.strategy("claim")?, - ParsedStrategy::Claim(ClaimStrategy::BatchOp) - ) { - return self.claim_via_batch(id, actor).await; + match self.strategy("claim")? { + ParsedStrategy::Claim(ClaimStrategy::AtomicCommand) => { + let values = HashMap::from([("id", id.to_string()), ("actor", actor.to_string())]); + return self.claim_from_operation("claim", &values).await; + } + ParsedStrategy::Claim(ClaimStrategy::BatchOp) => { + return self.claim_via_batch(id, actor).await; + } + ParsedStrategy::Claim(ClaimStrategy::CompareAndSet) => {} + _ => bail!("backend '{}' has invalid claim strategy", self.backend.name), } let shown = self.show(id).await?; if shown.status != BeadStatus::Open { @@ -988,6 +1067,18 @@ impl BeadStore for CliBeadStore { } } +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"_@%+=:,./-".contains(&byte)) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + fn bf_update_batch_args( id: &BeadId, status: Option<&str>, diff --git a/src/bead_store/mod.rs b/src/bead_store/mod.rs index a2d14eab..0e5a4b21 100644 --- a/src/bead_store/mod.rs +++ b/src/bead_store/mod.rs @@ -34,11 +34,146 @@ use tracing::{debug, warn}; // Re-export the implementations so consumers don't need to change their imports pub use backend::{ - builtin_bead_backends, load_bead_backends, BeadBackend, BeadBackendCapabilities, - BeadBackendErrorMarkers, BeadBackendQuirk, BeadOperationSpec, ParseShape, + builtin_bead_backends, load_bead_backends, load_bead_backends_with_sources, BeadBackend, + BeadBackendCapabilities, BeadBackendErrorMarkers, BeadBackendQuirk, BeadOperationSpec, + LoadedBeadBackend, ParseShape, }; pub use cli_store::CliBeadStore; +/// How the executable in a resolved backend binding was selected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BackendBinarySource { + ExplicitPath, + Path, + DescriptorPath(PathBuf), +} + +/// Immutable descriptor/executable binding shared by runtime consumers. +#[derive(Debug, Clone)] +pub struct ResolvedBeadBackend { + pub descriptor: BeadBackend, + pub binary: PathBuf, + pub descriptor_source: PathBuf, + pub binary_source: BackendBinarySource, +} + +/// Backend-derived command fragments exposed to prompt construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BeadPromptCommands { + pub cli: String, + pub dep_add: String, +} + +/// Operator-owned descriptor directory. Workspace content selects a descriptor +/// by name but cannot redirect where descriptor code is loaded from. +pub fn bead_backend_descriptor_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config/needle/bead-backends") +} + +pub fn resolve_configured_backend( + config: &crate::config::BeadCliConfig, +) -> Result { + resolve_configured_backend_in(config, &bead_backend_descriptor_dir()) +} + +/// Resolve one configured descriptor and executable. Descriptor loading and +/// binary selection happen exactly once so downstream call sites cannot mix a +/// command grammar with a different installed CLI. +pub fn resolve_configured_backend_in( + config: &crate::config::BeadCliConfig, + descriptor_dir: &Path, +) -> Result { + let name = config.backend.descriptor_name().ok_or_else(|| { + anyhow::anyhow!( + "auto bead backend selection is diagnostic-only; configure bead_cli.backend explicitly" + ) + })?; + let loaded = load_bead_backends_with_sources(descriptor_dir, &builtin_bead_backends())?; + let selected = loaded.get(name).ok_or_else(|| { + let mut available = loaded.keys().cloned().collect::>(); + available.sort(); + anyhow::anyhow!( + "configured bead backend descriptor '{name}' was not found in {} (available: {})", + descriptor_dir.display(), + available.join(", ") + ) + })?; + + let (binary, binary_source) = if let Some(path) = &config.path { + if !is_executable(path) { + bail!( + "configured bead backend executable is missing or not executable: {}", + path.display() + ); + } + (path.clone(), BackendBinarySource::ExplicitPath) + } else if let Some(path) = find_executable_on_path(&selected.descriptor.binary) { + (path, BackendBinarySource::Path) + } else if let Some(path) = selected + .descriptor + .detect_paths + .iter() + .map(|path| expand_home_path(path)) + .find(|path| is_executable(path)) + { + let source = BackendBinarySource::DescriptorPath(path.clone()); + (path, source) + } else { + bail!( + "executable '{}' for bead backend '{}' was not found on PATH or in its descriptor paths", + selected.descriptor.binary, + selected.descriptor.name + ); + }; + + Ok(ResolvedBeadBackend { + descriptor: selected.descriptor.clone(), + binary, + descriptor_source: selected.source.clone(), + binary_source, + }) +} + +fn find_executable_on_path(binary: &str) -> Option { + let path = Path::new(binary); + if path.components().count() > 1 { + return is_executable(path).then(|| path.to_path_buf()); + } + std::env::var_os("PATH") + .into_iter() + .flat_map(|value| std::env::split_paths(&value).collect::>()) + .map(|directory| directory.join(binary)) + .find(|candidate| is_executable(candidate)) +} + +fn expand_home_path(path: &Path) -> PathBuf { + let Some(text) = path.to_str() else { + return path.to_path_buf(); + }; + if text == "~" || text.starts_with("~/") { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(text.trim_start_matches("~/")); + } + } + path.to_path_buf() +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + /// Open the bead store explicitly bound by the target workspace's resolved /// configuration. This is the production entry point: executable discovery /// alone is never treated as evidence of store ownership. @@ -48,6 +183,26 @@ pub fn open_configured( model: Option, harness: Option, harness_version: Option, +) -> Result> { + open_configured_in( + config, + workspace, + model, + harness, + harness_version, + &bead_backend_descriptor_dir(), + ) +} + +/// Descriptor-directory-injected form used by hermetic tests and embedders. +/// Production callers use [`open_configured`] and the operator-owned directory. +pub fn open_configured_in( + config: &crate::config::BeadCliConfig, + workspace: PathBuf, + model: Option, + harness: Option, + harness_version: Option, + descriptor_dir: &Path, ) -> Result> { if matches!(config.backend, crate::config::BeadBackend::Auto) { bail!( @@ -57,60 +212,59 @@ pub fn open_configured( ); } - let (backend, binary, _source) = - crate::config::resolve_bead_cli(config).with_context(|| { - format!( - "failed to resolve bead_cli.backend for workspace {}", - workspace.display() - ) - })?; - verify_backend_identity(&backend, &binary, &workspace)?; - if backend == crate::config::Backend::Bead { - verify_bead_rs_capabilities(&binary, &workspace)?; - } - - match backend { - crate::config::Backend::Bead => { - let descriptor = builtin_bead_backends() - .into_iter() - .find(|candidate| candidate.name == "bead-rs") - .ok_or_else(|| anyhow::anyhow!("built-in bead-rs descriptor is missing"))?; - Ok(Arc::new(CliBeadStore::new( - descriptor, - binary, - workspace, - model, - harness, - harness_version, - )?)) - } + let resolved = resolve_configured_backend_in(config, descriptor_dir).with_context(|| { + format!( + "failed to resolve bead_cli.backend for workspace {}", + workspace.display() + ) + })?; + verify_resolved_backend(&resolved, &workspace)?; + Ok(Arc::new(CliBeadStore::new( + resolved.descriptor, + resolved.binary, + workspace, + model, + harness, + harness_version, + )?)) +} + +/// Verify the executable side of an immutable binding before any store +/// operation. Runtime consumers that need the concrete descriptor retain and +/// verify the same value rather than resolving a second binding. +pub(crate) fn verify_resolved_backend( + resolved: &ResolvedBeadBackend, + workspace: &Path, +) -> Result<()> { + verify_backend_identity(&resolved.descriptor, &resolved.binary, workspace)?; + if resolved.descriptor.name == "bead-rs" { + verify_bead_rs_capabilities(&resolved.binary, workspace)?; } + Ok(()) } fn verify_backend_identity( - backend: &crate::config::Backend, + descriptor: &BeadBackend, binary: &Path, workspace: &Path, +) -> Result<()> { + verify_backend_identity_with_timeout( + descriptor, + binary, + workspace, + std::time::Duration::from_secs(5), + ) +} + +fn verify_backend_identity_with_timeout( + descriptor: &BeadBackend, + binary: &Path, + workspace: &Path, + timeout: std::time::Duration, ) -> Result<()> { use regex::Regex; - use std::io::Read; use std::process::Stdio; - // Get the backend descriptor for this backend type - let descriptor_name = match backend { - crate::config::Backend::Bead => "bead-rs", - }; - - let descriptor = builtin_bead_backends() - .into_iter() - .find(|d| d.name == descriptor_name) - .ok_or_else(|| { - anyhow::anyhow!( - "built-in backend descriptor '{}' not found", - descriptor_name - ) - })?; - let child = spawn_with_etxtbsy_retry_sync_child( || { std::process::Command::new(binary) @@ -129,7 +283,15 @@ fn verify_backend_identity( ) })?; let mut guard = ProcessGuardSync::new(child); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let stdout_reader = guard + .get_mut() + .and_then(|child| child.stdout.take()) + .map(spawn_bounded_reader); + let stderr_reader = guard + .get_mut() + .and_then(|child| child.stderr.take()) + .map(spawn_bounded_reader); + let deadline = std::time::Instant::now() + timeout; let status = loop { match guard.get_mut().map(|c| c.try_wait()) { Some(Ok(Some(status))) => { @@ -163,13 +325,14 @@ fn verify_backend_identity( } std::thread::sleep(std::time::Duration::from_millis(10)); }; - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - if let Some(mut stream) = guard.get_mut().and_then(|c| c.stdout.take()) { - stream.read_to_end(&mut stdout)?; - } - if let Some(mut stream) = guard.get_mut().and_then(|c| c.stderr.take()) { - stream.read_to_end(&mut stderr)?; + let (stdout, stdout_truncated) = join_bounded_reader(stdout_reader)?; + let (stderr, stderr_truncated) = join_bounded_reader(stderr_reader)?; + if stdout_truncated || stderr_truncated { + bail!( + "bead backend identity output exceeded 65536 bytes for workspace {} at {}", + workspace.display(), + binary.display() + ); } let stdout = String::from_utf8_lossy(&stdout); let stderr = String::from_utf8_lossy(&stderr); @@ -205,39 +368,41 @@ fn verify_backend_identity( ); } - // Extract the backend name from the version output - // The pattern captures the name at the start (e.g., "bf " or "bead ") - let captured_name = trimmed_output - .split_whitespace() - .next() - .ok_or_else(|| { - anyhow::anyhow!( - "bead backend identity extraction failed for workspace {}: binary at {} reported empty version output", - workspace.display(), - binary.display() - ) - })?; - - // Map the captured name to the expected backend name - // Both "bf" and "bead" should map to their respective backend names - let expected_names = match descriptor.name.as_str() { - "bead-forge" => vec!["bf", "bead-forge"], - "bead-rs" => vec!["bead", "bead-rs"], - _ => vec![descriptor.name.as_str()], - }; + Ok(()) +} - if !expected_names.contains(&captured_name) { - bail!( - "bead backend identity mismatch for workspace {}: binary at {} reported name {:?}, but expected one of {:?} for backend '{}'", - workspace.display(), - binary.display(), - captured_name, - expected_names, - descriptor.name - ); - } +const MAX_IDENTITY_OUTPUT_BYTES: usize = 64 * 1024; +type BoundedReader = std::thread::JoinHandle, bool)>>; - Ok(()) +fn spawn_bounded_reader(mut reader: R) -> BoundedReader +where + R: std::io::Read + Send + 'static, +{ + std::thread::spawn(move || { + let mut retained = Vec::new(); + let mut chunk = [0_u8; 8192]; + let mut truncated = false; + loop { + let read = reader.read(&mut chunk)?; + if read == 0 { + break; + } + let remaining = MAX_IDENTITY_OUTPUT_BYTES.saturating_sub(retained.len()); + retained.extend_from_slice(&chunk[..read.min(remaining)]); + truncated |= read > remaining; + } + Ok((retained, truncated)) + }) +} + +fn join_bounded_reader(reader: Option) -> Result<(Vec, bool)> { + match reader { + Some(reader) => reader + .join() + .map_err(|_| anyhow::anyhow!("bead backend identity output reader panicked"))? + .context("failed to read bead backend identity output"), + None => Ok((Vec::new(), false)), + } } /// Derive the expected backend name from the binary filename. @@ -1144,6 +1309,12 @@ pub struct NewChild<'a> { /// Abstract interface to the bead backend. #[async_trait] pub trait BeadStore: Send + Sync { + /// Render the command grammar agents should see in prompts. Stores without + /// an agent-facing CLI may return `None` and require custom templates. + fn prompt_commands(&self) -> Option { + None + } + /// Whether an error indicates corruption according to this store's backend. fn is_corruption_error(&self, _message: &str) -> bool { false @@ -1430,6 +1601,43 @@ mod tests { assert!(open_configured(&config, workspace.path().to_path_buf(), None, None, None).is_ok()); } + #[cfg(unix)] + #[test] + fn configured_store_opens_external_descriptor_from_operator_directory() { + let _environment = crate::util::test_env::isolate_env(); + let home = tempfile::tempdir().unwrap(); + std::env::set_var("HOME", home.path()); + let descriptor_dir = home.path().join(".config/needle/bead-backends"); + std::fs::create_dir_all(&descriptor_dir).unwrap(); + + let workspace = tempfile::tempdir().unwrap(); + let binary = version_fixture(workspace.path(), "fixture-cli", "fixture-cli 1.0.0"); + let mut descriptor = builtin_bead_backends().remove(0); + descriptor.name = "fixture-external".to_string(); + descriptor.binary = "fixture-cli".to_string(); + descriptor.detect_paths = vec![binary.clone()]; + descriptor.identity_pattern = r"^fixture-cli 1\.0\.0".to_string(); + descriptor.verified_against = "fixture-cli 1.0.0".to_string(); + descriptor.verified_on = "2026-09-05".to_string(); + std::fs::write( + descriptor_dir.join("fixture-external.yaml"), + serde_yaml::to_string(&descriptor).unwrap(), + ) + .unwrap(); + + let config = crate::config::BeadCliConfig { + backend: crate::config::BeadBackend::External("fixture-external".to_string()), + path: None, + }; + let store = + open_configured(&config, workspace.path().to_path_buf(), None, None, None).unwrap(); + + assert_eq!( + store.prompt_commands().unwrap().cli, + binary.display().to_string() + ); + } + #[cfg(unix)] #[test] fn configured_store_rejects_identity_mismatch() { @@ -3095,8 +3303,8 @@ esac std::fs::set_permissions(&bead_rs, perm).unwrap(); // Test that verify_backend_identity succeeds immediately for healthy binary - let result = - verify_backend_identity(&crate::config::Backend::Bead, &bead_rs, workspace.path()); + let descriptor = builtin_bead_backends().remove(0); + let result = verify_backend_identity(&descriptor, &bead_rs, workspace.path()); assert!( result.is_ok(), @@ -3126,11 +3334,8 @@ echo "bf 0.4.1" std::fs::set_permissions(&wrong_binary, perm).unwrap(); // Test that identity mismatch is caught and properly reported - let result = verify_backend_identity( - &crate::config::Backend::Bead, - &wrong_binary, - workspace.path(), - ); + let descriptor = builtin_bead_backends().remove(0); + let result = verify_backend_identity(&descriptor, &wrong_binary, workspace.path()); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); @@ -3141,6 +3346,40 @@ echo "bf 0.4.1" ); } + #[cfg(unix)] + #[test] + fn verify_backend_identity_times_out_and_terminates_the_probe() { + use std::os::unix::fs::PermissionsExt; + + let workspace = tempfile::tempdir().unwrap(); + let slow_binary = workspace.path().join("slow-backend-fixture"); + std::fs::write( + &slow_binary, + r#"#!/bin/sh +sleep 5 +echo "bead-rs 0.1.0" +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&slow_binary).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&slow_binary, permissions).unwrap(); + + let descriptor = builtin_bead_backends().remove(0); + let started = std::time::Instant::now(); + let error = verify_backend_identity_with_timeout( + &descriptor, + &slow_binary, + workspace.path(), + std::time::Duration::from_millis(30), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("timed out"), "{error}"); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + } + #[cfg(unix)] #[test] fn verify_backend_identity_propagates_spawn_failure() { @@ -3163,11 +3402,8 @@ exit 1 std::fs::set_permissions(&failing_binary, perm).unwrap(); // Test that spawn failure is properly propagated - let result = verify_backend_identity( - &crate::config::Backend::Bead, - &failing_binary, - workspace.path(), - ); + let descriptor = builtin_bead_backends().remove(0); + let result = verify_backend_identity(&descriptor, &failing_binary, workspace.path()); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); @@ -3186,11 +3422,8 @@ exit 1 let nonexistent = workspace.path().join("nonexistent-binary"); // Test that missing binary error is properly propagated - let result = verify_backend_identity( - &crate::config::Backend::Bead, - &nonexistent, - workspace.path(), - ); + let descriptor = builtin_bead_backends().remove(0); + let result = verify_backend_identity(&descriptor, &nonexistent, workspace.path()); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); diff --git a/src/bead_store/strategies.rs b/src/bead_store/strategies.rs index 87b2d613..8342c923 100644 --- a/src/bead_store/strategies.rs +++ b/src/bead_store/strategies.rs @@ -41,6 +41,10 @@ pub trait OperationStrategy { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ClaimStrategy { + /// One descriptor-rendered command performs the explicit claim atomically + /// and returns the normalized claim response contract. + AtomicCommand, + /// Compare-and-set claim: read current state, verify assignee is unset or /// matches the expected actor, then update. /// @@ -213,6 +217,7 @@ macro_rules! impl_operation_strategy { } impl_operation_strategy!(ClaimStrategy, "claim", { + ClaimStrategy::AtomicCommand => "atomic_command", ClaimStrategy::CompareAndSet => "compare_and_set", ClaimStrategy::BatchOp => "batch_op", }); @@ -337,6 +342,11 @@ pub trait ClaimStrategyOperations: Send + Sync { /// Claim one explicit issue in a single backend transaction. async fn batch_claim(&self, bead_id: &BeadId, actor: &str) -> anyhow::Result; + /// Claim one explicit issue through a descriptor-rendered atomic command. + async fn atomic_claim(&self, _bead_id: &BeadId, _actor: &str) -> anyhow::Result { + anyhow::bail!("atomic command claim is not implemented by this store") + } + /// Atomically select and claim the next ready issue. async fn atomic_claim_auto(&self, actor: &str) -> anyhow::Result; @@ -392,6 +402,7 @@ pub async fn execute_claim_strategy( actor: &str, ) -> anyhow::Result { match strategy { + ClaimStrategy::AtomicCommand => operations.atomic_claim(bead_id, actor).await, ClaimStrategy::CompareAndSet => { for _ in 0..MAX_COMPARE_AND_SET_ATTEMPTS { let bead = operations.show_for_claim(bead_id).await?; @@ -932,6 +943,10 @@ mod tests { #[test] fn claim_strategy_serializes_correctly() { // Verify snake_case serialization for descriptor YAML compatibility + assert_eq!( + serde_json::to_string(&ClaimStrategy::AtomicCommand).unwrap(), + r#""atomic_command""# + ); assert_eq!( serde_json::to_string(&ClaimStrategy::CompareAndSet).unwrap(), r#""compare_and_set""# @@ -945,6 +960,10 @@ mod tests { #[test] fn claim_strategy_deserializes_from_snake_case() { // Verify we can deserialize from the snake_case form used in descriptors + assert_eq!( + serde_json::from_str::(r#""atomic_command""#).unwrap(), + ClaimStrategy::AtomicCommand + ); assert_eq!( serde_json::from_str::(r#""compare_and_set""#).unwrap(), ClaimStrategy::CompareAndSet @@ -1456,7 +1475,11 @@ mod tests { #[test] fn claim_strategy_all_variants_documented() { // Verify all claim strategy variants are documented with race semantics - let variants = [ClaimStrategy::CompareAndSet, ClaimStrategy::BatchOp]; + let variants = [ + ClaimStrategy::AtomicCommand, + ClaimStrategy::CompareAndSet, + ClaimStrategy::BatchOp, + ]; for variant in variants { // Each variant should serialize correctly for YAML descriptors diff --git a/src/canary/mod.rs b/src/canary/mod.rs index 644d8ce8..2b9a75f6 100644 --- a/src/canary/mod.rs +++ b/src/canary/mod.rs @@ -30,6 +30,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; // ────────────────────────────────────────────────────────────────────────────── // CanaryTestResult @@ -351,14 +352,16 @@ impl CanaryRunner { let backend = &bead_cli.backend; // Verify the backend binary exists - let (backend_type, binary_path, _source) = crate::config::resolve_bead_cli(bead_cli) - .with_context(|| { + let resolved = + crate::bead_store::resolve_configured_backend(bead_cli).with_context(|| { format!( "failed to resolve bead CLI backend '{}' for canary workspace {}", backend, self.canary_workspace.display() ) })?; + crate::bead_store::verify_resolved_backend(&resolved, &self.canary_workspace)?; + let binary_path = resolved.binary; if !binary_path.exists() { bail!( @@ -366,7 +369,7 @@ impl CanaryRunner { Ensure the backend is installed and accessible, or update bead_cli.path in {}/.needle.yaml", backend, binary_path.display(), - backend_type, + resolved.descriptor.name, self.canary_workspace.display() ); } @@ -374,7 +377,7 @@ impl CanaryRunner { tracing::info!( backend = %backend, binary = %binary_path.display(), - resolved_backend = %backend_type, + resolved_backend = %resolved.descriptor.name, "canary workspace bead backend validated" ); @@ -603,18 +606,29 @@ impl CanaryRunner { self.canary_workspace.display() ) })?; - let (_, binary, _source) = - crate::config::resolve_bead_cli(&bead_cli).with_context(|| { + let resolved = + crate::bead_store::resolve_configured_backend(&bead_cli).with_context(|| { format!( "failed to resolve canary workspace bead backend '{}'", bead_cli.backend ) })?; + crate::bead_store::verify_resolved_backend(&resolved, &self.canary_workspace)?; + let store = crate::bead_store::CliBeadStore::new( + resolved.descriptor, + resolved.binary, + self.canary_workspace.clone(), + None, + None, + None, + )?; + let values = HashMap::from([("id", bead_id.to_string())]); + let show_args = store.render_operation("show", &values)?; let output = crate::bead_store::spawn_with_etxtbsy_retry_sync( || { - Command::new(&binary) - .args(["show", bead_id, "--json"]) + Command::new(store.binary()) + .args(&show_args) .current_dir(&self.canary_workspace) .output() }, @@ -624,7 +638,7 @@ impl CanaryRunner { .with_context(|| { format!( "failed to run {} show for backend '{}'", - binary.display(), + store.binary().display(), bead_cli.backend ) })?; @@ -946,7 +960,21 @@ mod tests { let binary = root.path().join("bound-backend"); std::fs::write( &binary, - format!("#!/bin/sh\nprintf '%s\\n' '{projection}'\n"), + format!( + r#"#!/bin/sh +case "$1" in + --version) + echo 'bead 0.1.3' + ;; + capabilities) + echo '{{"implementation":"bead-rs","atomic_claim":true,"statuses":["open","in_progress","deferred","closed"],"schemas":[{{"schema_ref":"urn:bead-rs:schema:issue:native-v1"}},{{"schema_ref":"urn:bead-rs:schema:event:native-v1"}},{{"schema_ref":"urn:bead-rs:schema:field-guide:native-v1"}}],"commands":["ref","data","query"]}}' + ;; + show) + printf '%s\n' '{projection}' + ;; +esac +"# + ), ) .unwrap(); let mut permissions = std::fs::metadata(&binary).unwrap().permissions(); @@ -1798,7 +1826,20 @@ mod tests { // Mock a bead binary let bead_path = tmp.path().join("bead"); - std::fs::write(&bead_path, "#!/bin/sh\nexit 0").unwrap(); + std::fs::write( + &bead_path, + r#"#!/bin/sh +case "$1" in + capabilities) + printf '%s\n' '{"implementation":"bead-rs","atomic_claim":true,"statuses":["open","in_progress","deferred","closed"],"schemas":[{"schema_ref":"urn:bead-rs:schema:issue:native-v1"},{"schema_ref":"urn:bead-rs:schema:event:native-v1"},{"schema_ref":"urn:bead-rs:schema:field-guide:native-v1"}],"commands":["ref","data","query"]}' + ;; + --version) + printf 'bead 0.1.0\n' + ;; +esac +"#, + ) + .unwrap(); #[cfg(unix)] { @@ -1820,17 +1861,12 @@ mod tests { let runner = CanaryRunner::new(PathBuf::from("/tmp/.needle"), workspace.to_path_buf(), 300); - // Should succeed when bead backend is explicitly set and binary exists + // Should succeed when the explicitly bound binary reports the expected + // backend identity. let result = runner.validate_bead_backend_binding(); - // This will fail because bead is not on PATH, but we're testing that - // it passes the auto check at least - // In a real scenario, we'd need to set up PATH or use explicit path assert!( - match &result { - Ok(()) => true, - Err(err) => err.to_string().contains("binary not found"), - }, - "should accept bead-rs backend or fail only with binary not found: {:?}", + result.is_ok(), + "should accept bead-rs backend: {:?}", result ); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ce55779f..21ece041 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -309,7 +309,7 @@ pub enum CliCommand { /// Explicitly bind one repository to a bead backend descriptor. #[command(name = "bead-backend-bind")] BeadBackendBind { - /// Builtin backend name (`bead-rs` or `bead-forge`). + /// Loaded backend descriptor name (for example `bead-rs`). backend: String, /// Repository to update. #[arg(default_value = ".")] @@ -2926,29 +2926,27 @@ fn cmd_test_agent(name: &str) -> Result<()> { } fn cmd_bead_backend(name: &str, workspace: &Path) -> Result<()> { - if !matches!(name, "bead-rs" | "bead-forge") { - bail!("unknown builtin bead backend '{name}'"); - } let workspace = workspace .canonicalize() .with_context(|| format!("workspace does not exist: {}", workspace.display()))?; let backend = match name { - "bead-rs" => crate::config::BeadBackend::Bead, - "bead-forge" => bail!("bead-forge backend is no longer supported; use 'bead-rs' instead"), - _ => bail!("unknown builtin bead backend '{name}'"), + "bead-rs" | "bead" => crate::config::BeadBackend::Bead, + "auto" => bail!("auto is diagnostic-only; name a descriptor explicitly"), + other => crate::config::BeadBackend::External(other.to_string()), }; let config = crate::config::BeadCliConfig { backend, path: None, }; - let (_, binary, _source) = crate::config::resolve_bead_cli(&config)?; - crate::bead_store::open_configured(&config, workspace.clone(), None, None, None)?; - let descriptor = crate::bead_store::builtin_bead_backends() - .into_iter() - .find(|descriptor| descriptor.name == name) - .ok_or_else(|| anyhow::anyhow!("builtin descriptor '{name}' is missing"))?; + let resolved = crate::bead_store::resolve_configured_backend(&config)?; + crate::bead_store::verify_resolved_backend(&resolved, &workspace)?; + let descriptor = resolved.descriptor; println!("backend: {}", descriptor.name); - println!("binary: {}", binary.display()); + println!("binary: {}", resolved.binary.display()); + println!( + "descriptor_source: {}", + resolved.descriptor_source.display() + ); println!("verified_against: {}", descriptor.verified_against); println!("atomic_claim: {}", descriptor.capabilities.atomic_claim); println!( @@ -3015,15 +3013,21 @@ fn cmd_bead_backend_audit(root: &Path) -> Result<()> { } fn cmd_bead_backend_bind(backend: &str, workspace: &Path) -> Result<()> { - if !matches!(backend, "bead-rs" | "bead-forge") { - bail!("unknown builtin bead backend '{backend}'"); - } let workspace = workspace .canonicalize() .with_context(|| format!("workspace does not exist: {}", workspace.display()))?; if !workspace.join(".beads").is_dir() { bail!("{} is not a bead workspace", workspace.display()); } + let backend_config = match backend { + "bead-rs" | "bead" => crate::config::BeadBackend::Bead, + "auto" => bail!("auto is diagnostic-only; name a descriptor explicitly"), + other => crate::config::BeadBackend::External(other.to_string()), + }; + crate::bead_store::resolve_configured_backend(&crate::config::BeadCliConfig { + backend: backend_config, + path: None, + })?; let path = workspace.join(".needle.yaml"); let mut root = if path.exists() { serde_yaml::from_str::(&std::fs::read_to_string(&path)?) @@ -4530,7 +4534,7 @@ fn doctor_check_bead_store( } fn doctor_check_bead_backend(config: &Config) -> CheckResult { - let (backend, path, source) = match crate::config::resolve_bead_cli(&config.bead_cli) { + let resolved = match crate::bead_store::resolve_configured_backend(&config.bead_cli) { Ok(resolved) => resolved, Err(error) => { let checked = bead_cli_candidates_checked(&config.bead_cli); @@ -4548,19 +4552,22 @@ fn doctor_check_bead_backend(config: &Config) -> CheckResult { return CheckResult::fail("Bead backend", message).with_fix(fix); } }; - let name = match backend { - crate::config::Backend::Bead => "bead-rs", - }; - let Some(descriptor) = crate::bead_store::builtin_bead_backends() - .into_iter() - .find(|descriptor| descriptor.name == name) - else { - return CheckResult::fail("Bead backend", format!("descriptor {name} is missing")) - .with_fix("cargo install --git https://github.com/jedarden/bead-rs --bin bead"); - }; + if let Err(error) = + crate::bead_store::verify_resolved_backend(&resolved, &config.workspace.default) + { + return CheckResult::fail( + "Bead backend", + format!("configured backend failed identity verification: {error:#}"), + ); + } + let descriptor = resolved.descriptor; let mut detail = vec![ - format!("CLI path: {}", path.display()), - format!("source: {}", source), + format!("CLI path: {}", resolved.binary.display()), + format!( + "descriptor source: {}", + resolved.descriptor_source.display() + ), + format!("binary source: {:?}", resolved.binary_source), format!("verified against: {}", descriptor.verified_against), ]; if !descriptor.capabilities.transactional_batch { @@ -4585,6 +4592,13 @@ fn bead_cli_candidates_checked(config: &crate::config::BeadCliConfig) -> String crate::config::BeadBackend::Auto => "bead, bf, br".to_string(), crate::config::BeadBackend::Bead => "bead".to_string(), crate::config::BeadBackend::Br => "br".to_string(), + crate::config::BeadBackend::External(ref name) => { + format!( + "descriptor '{}' in {}", + name, + crate::bead_store::bead_backend_descriptor_dir().display() + ) + } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 29689d34..e57bbf2f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -39,7 +39,7 @@ use std::fmt; use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; -use serde::{de::Visitor, Deserialize, Deserializer, Serialize}; +use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; use crate::cost::{BudgetConfig, PricingConfig}; @@ -695,17 +695,27 @@ pub struct WorkspaceLabelsOverride { /// Bead CLI backend enumeration. /// /// Represents the available bead CLI backends that can be configured. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BeadBackend { /// Auto-detect the appropriate backend - #[serde(rename = "auto")] Auto, /// br - deprecated alias for bead (legacy support) - #[serde(rename = "br")] Br, /// bead (bead-rs alias for backward compatibility) - native CLI - #[serde(rename = "bead-rs", alias = "bead")] Bead, + /// Operator-provided descriptor loaded from the configured descriptor directory. + External(String), +} + +impl BeadBackend { + /// Descriptor name selected by this configuration value. + pub fn descriptor_name(&self) -> Option<&str> { + match self { + BeadBackend::Auto => None, + BeadBackend::Br | BeadBackend::Bead => Some("bead-rs"), + BeadBackend::External(name) => Some(name), + } + } } impl std::fmt::Display for BeadBackend { @@ -714,6 +724,34 @@ impl std::fmt::Display for BeadBackend { BeadBackend::Auto => write!(f, "auto"), BeadBackend::Br => write!(f, "br"), BeadBackend::Bead => write!(f, "bead-rs"), + BeadBackend::External(name) => write!(f, "{name}"), + } + } +} + +impl Serialize for BeadBackend { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for BeadBackend { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "auto" => Ok(BeadBackend::Auto), + "br" => Ok(BeadBackend::Br), + "bead" | "bead-rs" => Ok(BeadBackend::Bead), + _ if value.trim().is_empty() => Err(serde::de::Error::custom( + "bead_cli.backend must not be empty", + )), + _ => Ok(BeadBackend::External(value)), } } } @@ -766,12 +804,15 @@ impl ConfigTier for BeadCliConfig { pub enum Backend { /// bead-rs native CLI Bead, + /// Operator-provided CLI descriptor. + External(String), } impl std::fmt::Display for Backend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Backend::Bead => write!(f, "bead"), + Backend::External(name) => write!(f, "{name}"), } } } @@ -903,6 +944,7 @@ pub fn resolve_bead_cli(config: &BeadCliConfig) -> Result<(Backend, PathBuf, Bac let backend = match config.backend { BeadBackend::Bead | BeadBackend::Br => Backend::Bead, BeadBackend::Auto => detect_backend_from_path(path)?, + BeadBackend::External(ref name) => Backend::External(name.clone()), }; return Ok((backend, path.clone(), BackendSource::ExplicitPath)); } else { @@ -960,6 +1002,9 @@ pub fn resolve_bead_cli(config: &BeadCliConfig) -> Result<(Backend, PathBuf, Bac "no bead CLI found (tried: bead on PATH, {home}/.local/bin/bead, /usr/local/cargo/bin/bead)" ) } + BeadBackend::External(ref name) => { + bail!("external bead backend '{name}' must be resolved with its descriptor binding") + } } } @@ -1074,7 +1119,7 @@ pub fn detect_bead_backend(workspace_root: &Path) -> Result<(Backend, PathBuf)> ), "br" => Some(BeadBackend::Bead), "auto" => Some(BeadBackend::Auto), - other => bail!("unknown bead_cli.backend value: '{}'", other), + other => Some(BeadBackend::External(other.to_string())), } } else { None @@ -1144,6 +1189,9 @@ pub fn detect_bead_backend(workspace_root: &Path) -> Result<(Backend, PathBuf)> "no bead CLI found (tried: bead on PATH, {home}/.local/bin/bead, /usr/local/cargo/bin/bead, br on PATH)" ) } + BeadBackend::External(name) => { + bail!("external bead backend '{name}' requires descriptor-aware resolution") + } } } @@ -2466,11 +2514,9 @@ mod tests { } #[test] - fn test_bead_backend_deserialize_invalid_value() { - // Invalid values should fail deserialization - let yaml = "invalid-backend"; - let result: Result = serde_yaml::from_str(yaml); - assert!(result.is_err()); + fn test_bead_backend_deserializes_external_descriptor_name() { + let backend: BeadBackend = serde_yaml::from_str("fixture-remote").unwrap(); + assert_eq!(backend, BeadBackend::External("fixture-remote".to_string())); } // ─── BeadCliConfig path alias tests ─────────────────────────────────────────── @@ -2905,7 +2951,7 @@ path: /path with spaces/to/bead let result = detect_bead_backend(ws_root); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("unknown bead_cli.backend")); + assert!(err_msg.contains("requires descriptor-aware resolution")); } #[serial] @@ -3030,12 +3076,13 @@ path: /path/to/./bead #[test] fn test_bead_backend_case_sensitivity() { - // Backend names should be case-sensitive - let result: Result = serde_yaml::from_str("Auto"); - assert!(result.is_err(), "Uppercase 'Auto' should fail"); + // Reserved names remain case-sensitive; differently cased values are + // external descriptor names rather than aliases for `auto`. + let backend: BeadBackend = serde_yaml::from_str("Auto").unwrap(); + assert_eq!(backend, BeadBackend::External("Auto".to_string())); - let result: Result = serde_yaml::from_str("AUTO"); - assert!(result.is_err(), "Uppercase 'AUTO' should fail"); + let backend: BeadBackend = serde_yaml::from_str("AUTO").unwrap(); + assert_eq!(backend, BeadBackend::External("AUTO".to_string())); // Lowercase should work let backend: BeadBackend = serde_yaml::from_str("auto").unwrap(); @@ -3124,16 +3171,12 @@ path: /path/to/./bead // ─── Error handling and edge case tests ────────────────────────────────────── #[test] - fn test_bead_backend_invalid_string_rejects() { - let invalid_inputs = vec!["invalid", "unknown", "foo", "bar", ""]; - for input in invalid_inputs { - let result: Result = serde_yaml::from_str(input); - assert!( - result.is_err(), - "Should reject invalid backend: '{}'", - input - ); + fn test_bead_backend_external_strings_are_descriptor_names() { + for input in ["invalid", "unknown", "foo", "bar"] { + let backend: BeadBackend = serde_yaml::from_str(input).unwrap(); + assert_eq!(backend, BeadBackend::External(input.to_string())); } + assert!(serde_yaml::from_str::("''").is_err()); } #[test] @@ -13605,6 +13648,7 @@ agent: BeadBackend::Auto => "auto", BeadBackend::Br => "br", BeadBackend::Bead => "bead-rs", + BeadBackend::External(ref name) => name, }; assert_eq!( backend_str, expected_backend, diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 45de9214..beca846a 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -550,7 +550,7 @@ fn extra_vars_for_template(name: &str) -> Option<&'static [&'static str]> { } } -fn bead_commands_for_workspace(workspace: &Path) -> (&'static str, &'static str) { +fn bead_commands_for_workspace(workspace: &Path) -> (String, String) { let backend = std::fs::read_to_string(workspace.join(".needle.yaml")) .ok() .and_then(|text| serde_yaml::from_str::(&text).ok()) @@ -565,14 +565,14 @@ fn bead_commands_for_workspace(workspace: &Path) -> (&'static str, &'static str) if matches!(backend.as_deref(), Some("bf")) { // Explicit bf request (now unsupported) still defaults to bead-rs ( - "bead", - "bead dep add --kind blocks", + "bead".to_string(), + "bead dep add --kind blocks".to_string(), ) } else { // bead, bead-rs, or undeclared all default to bead-rs ( - "bead", - "bead dep add --kind blocks", + "bead".to_string(), + "bead dep add --kind blocks".to_string(), ) } } @@ -643,6 +643,8 @@ pub struct PromptBuilder { skill_library: Option, /// A/B test variant configurations per template name. variants: BTreeMap>, + /// CLI grammar supplied by the already resolved store binding. + bead_commands: Option, } impl PromptBuilder { @@ -677,9 +679,26 @@ impl PromptBuilder { global_learnings_content: None, skill_library: None, variants: config.variants.clone(), + bead_commands: None, } } + /// Use the same descriptor/executable binding as the worker store rather + /// than independently inferring commands from workspace text. + pub fn with_bead_commands( + mut self, + commands: Option, + ) -> Self { + self.bead_commands = commands; + self + } + + /// Replace the command binding when a roaming worker enters or leaves a + /// workspace with its own explicitly selected backend. + pub fn set_bead_commands(&mut self, commands: Option) { + self.bead_commands = commands; + } + /// Create a new `PromptBuilder` with workspace-specific learnings. /// /// This variant loads the `.beads/learnings.md` file if it exists, @@ -861,7 +880,11 @@ impl PromptBuilder { let comments_section = format_comments(&bead.comments); // Substitute common variables. - let (bead_cli, dep_add_command) = bead_commands_for_workspace(workspace); + let (bead_cli, dep_add_command) = self + .bead_commands + .as_ref() + .map(|commands| (commands.cli.clone(), commands.dep_add.clone())) + .unwrap_or_else(|| bead_commands_for_workspace(workspace)); let mut content = template_content .replace("{bead_id}", bead.id.as_ref()) .replace("{bead_title}", &bead.title) @@ -871,8 +894,8 @@ impl PromptBuilder { .replace("{context_file_contents}", &context_file_contents) .replace("{workspace_instructions}", instructions) .replace("{worker_id}", worker_id) - .replace("{bead_cli}", bead_cli) - .replace("{dep_add_command}", dep_add_command); + .replace("{bead_cli}", &bead_cli) + .replace("{dep_add_command}", &dep_add_command); // Substitute strand-specific variables. for (var, value) in extra_vars { diff --git a/src/telemetry/otlp.rs b/src/telemetry/otlp.rs index 6c2c357f..83d9892f 100644 --- a/src/telemetry/otlp.rs +++ b/src/telemetry/otlp.rs @@ -767,15 +767,11 @@ impl OtlpSink { // but Linux provides a PATH_MAX upper bound. Use 1024 as a conservative // minimum (POSIX) and 16384 as a safe maximum. let mut buf = vec![0u8; 16384]; - let mut pwd = libc::passwd { - pw_name: std::ptr::null_mut(), - pw_passwd: std::ptr::null_mut(), - pw_uid: 0, - pw_gid: 0, - pw_gecos: std::ptr::null_mut(), - pw_dir: std::ptr::null_mut(), - pw_shell: std::ptr::null_mut(), - }; + // SAFETY: `passwd` is a C record of integer and pointer fields. A + // zeroed value is the required empty initialization for getpwuid_r and + // remains portable when libc exposes target-specific fields (Darwin + // also carries pw_change, pw_class, and pw_expire). + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; let mut result = std::ptr::null_mut(); // SAFETY: getpwuid_r writes into buffers we own and checks bounds. diff --git a/src/util.rs b/src/util.rs index c1bef069..71bc23bd 100644 --- a/src/util.rs +++ b/src/util.rs @@ -772,6 +772,10 @@ pub fn detect_bead_cli_backend( crate::config::BackendDetection::new(cli.backend_name().to_string(), cli.path) }) } + BeadBackend::External(name) => { + debug!(backend = %name, "external backend requires descriptor-aware resolution"); + None + } } } diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 39fe7631..9a76272a 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -875,7 +875,8 @@ impl Worker { &config.strands.explore.workspaces, &config.workspace.labels, ) - .with_global_learnings(&config.strands.learning.global_learnings_file); + .with_global_learnings(&config.strands.learning.global_learnings_file) + .with_bead_commands(store.prompt_commands()); let _ = telemetry.emit( EventKind::InitStepCompleted { step: "prompt_builder_setup".to_string(), @@ -2267,6 +2268,8 @@ impl Worker { Some(env!("CARGO_PKG_VERSION").to_string()), ) .context("failed to create bead store for remote workspace")?; + self.prompt_builder + .set_bead_commands(remote_store.prompt_commands()); self.store = remote_store.clone(); self.current_workspace = workspace.to_path_buf(); self.claimer = Claimer::new( @@ -2290,6 +2293,8 @@ impl Worker { fn restore_home_store(&mut self) { if !Arc::ptr_eq(&self.store, &self.home_store) { tracing::debug!("restoring home workspace store"); + self.prompt_builder + .set_bead_commands(self.home_store.prompt_commands()); self.store = self.home_store.clone(); self.current_workspace = self.config.workspace.default.clone(); self.claimer = Claimer::new( @@ -5006,6 +5011,7 @@ impl Worker { .with_global_learnings( &self.config.strands.learning.global_learnings_file, ) + .with_bead_commands(self.store.prompt_commands()) }) .and_then(|builder| { builder.validate()?; diff --git a/tests/dod-modes/run.sh b/tests/dod-modes/run.sh index 4392266b..b77b71ee 100755 --- a/tests/dod-modes/run.sh +++ b/tests/dod-modes/run.sh @@ -15,7 +15,8 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DOD="$REPO_ROOT/scripts/definition-of-done.sh" [[ -f "$DOD" ]] || { echo "missing $DOD" >&2; exit 1; } -extracted="$(mktemp "${TMPDIR:-/tmp}/dod-modes-XXXXXX.sh")" +extracted="$(mktemp "${TMPDIR:-/tmp}/dod-modes.XXXXXX")" +trap 'rm -f "$extracted"' EXIT for fn in needle_slow_targets needle_cargo_selector selected_cargo_targets needle_gate_skips_slow_lane; do awk -v f="^${fn}\\\\(\\\\)" '$0 ~ f, /^}/' "$DOD" >> "$extracted" done diff --git a/tests/external_backend_runtime.rs b/tests/external_backend_runtime.rs new file mode 100644 index 00000000..701b48db --- /dev/null +++ b/tests/external_backend_runtime.rs @@ -0,0 +1,299 @@ +#![cfg(unix)] + +use needle::bead_store::{ + builtin_bead_backends, load_bead_backends_with_sources, open_configured_in, + resolve_configured_backend_in, BeadStore, +}; +use needle::config::{BeadBackend as ConfigBackend, BeadCliConfig}; +use needle::types::{BeadId, ClaimResult}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +fn executable(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +fn descriptor(directory: &Path, binary: &Path, identity_pattern: &str) { + let mut descriptor = builtin_bead_backends().remove(0); + descriptor.name = "fixture-remote".to_string(); + descriptor.binary = "fixture-remote-cli".to_string(); + descriptor.detect_paths = vec![binary.to_path_buf()]; + descriptor.identity_pattern = identity_pattern.to_string(); + descriptor.verified_against = "fixture-remote 1.0.0".to_string(); + descriptor.verified_on = "2026-09-05".to_string(); + descriptor.operations.get_mut("claim").unwrap().argv = vec![ + "claim-one".to_string(), + "--id".to_string(), + "{id}".to_string(), + "--actor".to_string(), + "{actor}".to_string(), + ]; + descriptor.operations.get_mut("claim").unwrap().strategy = Some("atomic_command".to_string()); + descriptor.operations.get_mut("show").unwrap().argv = + vec!["get".to_string(), "--id".to_string(), "{id}".to_string()]; + descriptor.operations.get_mut("release").unwrap().argv = vec![ + "release-one".to_string(), + "--id".to_string(), + "{id}".to_string(), + ]; + fs::write( + directory.join("fixture-remote.yaml"), + serde_yaml::to_string(&descriptor).unwrap(), + ) + .unwrap(); +} + +fn config() -> BeadCliConfig { + BeadCliConfig { + backend: ConfigBackend::External("fixture-remote".to_string()), + path: None, + } +} + +fn fixture_script(version: &str, claim_response: &str) -> String { + format!( + r#"#!/bin/sh +case "$1" in + --version) + printf '%s\n' '{version}' + ;; + claim-one) + printf '<%s>\n' "$@" >> invocations.log + printf '%s\n' '{claim_response}' + ;; + get) + printf '<%s>\n' "$@" >> invocations.log + printf '%s\n' '{{"id":"work:alpha/1","title":"fixture","description":null,"priority":2,"status":"in_progress","assignee":"worker with spaces","labels":[],"source_repo":"","dependencies":[],"dependents":[],"comments":[],"created_at":"2026-09-05T00:00:00Z","updated_at":"2026-09-05T00:00:01Z"}}' + ;; + release-one) + printf '<%s>\n' "$@" >> invocations.log + touch released + ;; +esac +"# + ) +} + +#[test] +fn resolves_external_descriptor_with_provenance() { + let root = tempfile::tempdir().unwrap(); + let descriptors = root.path().join("descriptors"); + fs::create_dir(&descriptors).unwrap(); + let binary = root.path().join("fixture remote cli"); + executable( + &binary, + &fixture_script("fixture-remote 1.0.0", r#"{"outcome":"not_claimable"}"#), + ); + descriptor(&descriptors, &binary, r"^fixture-remote 1\.0\.0"); + + let resolved = resolve_configured_backend_in(&config(), &descriptors).unwrap(); + assert_eq!(resolved.descriptor.name, "fixture-remote"); + assert_eq!(resolved.binary, binary); + assert_eq!( + resolved.descriptor_source, + descriptors.join("fixture-remote.yaml") + ); +} + +#[tokio::test] +async fn external_atomic_claim_uses_descriptor_argv_without_shell_splitting() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + let descriptors = root.path().join("descriptors"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(&descriptors).unwrap(); + let binary = root.path().join("fixture remote cli"); + executable( + &binary, + &fixture_script( + "fixture-remote 1.0.0", + r#"{"outcome":"claimed","bead_id":"work:alpha/1"}"#, + ), + ); + descriptor(&descriptors, &binary, r"^fixture-remote 1\.0\.0"); + + let store: std::sync::Arc = + open_configured_in(&config(), workspace.clone(), None, None, None, &descriptors).unwrap(); + let commands = store.prompt_commands().unwrap(); + assert_eq!(commands.cli, format!("'{}'", binary.display())); + assert!(commands + .dep_add + .starts_with(&format!("'{}'", binary.display()))); + assert!(commands.dep_add.contains(" ")); + let result = store + .claim(&BeadId::from("work:alpha/1"), "worker with spaces") + .await + .unwrap(); + assert!(matches!(result, ClaimResult::Claimed(_))); + store.release(&BeadId::from("work:alpha/1")).await.unwrap(); + + let log = fs::read_to_string(workspace.join("invocations.log")).unwrap(); + assert!(log.contains("\n<--id>\n\n<--actor>\n\n")); + assert!(log.contains("\n<--id>\n\n")); + assert!(workspace.join("released").exists()); +} + +#[test] +fn identity_mismatch_fails_before_store_mutation() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + let descriptors = root.path().join("descriptors"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(&descriptors).unwrap(); + let binary = root.path().join("fixture-remote-cli"); + executable( + &binary, + &fixture_script("different-cli 9.9.9", r#"{"outcome":"claimed"}"#), + ); + descriptor(&descriptors, &binary, r"^fixture-remote 1\.0\.0"); + + let error = open_configured_in(&config(), workspace.clone(), None, None, None, &descriptors) + .err() + .expect("identity mismatch must fail") + .to_string(); + assert!(error.contains("identity mismatch"), "{error}"); + assert!(!workspace.join("invocations.log").exists()); + assert!(!workspace.join("released").exists()); +} + +#[test] +fn unknown_binding_does_not_fall_back_to_native_backend() { + let descriptors = tempfile::tempdir().unwrap(); + let missing = BeadCliConfig { + backend: ConfigBackend::External("not-installed".to_string()), + path: Some(PathBuf::from("/bin/true")), + }; + let error = resolve_configured_backend_in(&missing, descriptors.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("not-installed")); + assert!(error.contains("was not found")); +} + +#[test] +fn duplicate_operator_descriptors_are_rejected_as_ambiguous() { + let root = tempfile::tempdir().unwrap(); + let binary = root.path().join("fixture-remote-cli"); + executable( + &binary, + &fixture_script("fixture-remote 1.0.0", r#"{"outcome":"not_claimable"}"#), + ); + descriptor(root.path(), &binary, r"^fixture-remote 1\.0\.0"); + fs::copy( + root.path().join("fixture-remote.yaml"), + root.path().join("fixture-remote-copy.yaml"), + ) + .unwrap(); + + let error = load_bead_backends_with_sources(root.path(), &builtin_bead_backends()) + .unwrap_err() + .to_string(); + assert!(error.contains("ambiguous"), "{error}"); + assert!(error.contains("fixture-remote.yaml"), "{error}"); + assert!(error.contains("fixture-remote-copy.yaml"), "{error}"); +} + +#[tokio::test] +async fn malformed_atomic_claim_response_is_an_error_not_an_empty_queue() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + let descriptors = root.path().join("descriptors"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(&descriptors).unwrap(); + let binary = root.path().join("fixture-remote-cli"); + executable( + &binary, + &fixture_script("fixture-remote 1.0.0", r#"{"unexpected":true}"#), + ); + descriptor(&descriptors, &binary, r"^fixture-remote 1\.0\.0"); + + let store = open_configured_in(&config(), workspace, None, None, None, &descriptors).unwrap(); + let error = store + .claim(&BeadId::from("work:alpha/1"), "worker") + .await + .unwrap_err() + .to_string(); + assert!(error.contains("omitted normalized outcome"), "{error}"); +} + +#[tokio::test] +async fn atomic_claim_race_preserves_the_winning_actor() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + let descriptors = root.path().join("descriptors"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(&descriptors).unwrap(); + let binary = root.path().join("fixture-remote-cli"); + executable( + &binary, + &fixture_script( + "fixture-remote 1.0.0", + r#"{"outcome":"race_lost","claimed_by":"worker-b"}"#, + ), + ); + descriptor(&descriptors, &binary, r"^fixture-remote 1\.0\.0"); + + let store = open_configured_in(&config(), workspace, None, None, None, &descriptors).unwrap(); + let result = store + .claim(&BeadId::from("work:alpha/1"), "worker-a") + .await + .unwrap(); + assert!(matches!( + result, + ClaimResult::RaceLost { claimed_by } if claimed_by == "worker-b" + )); +} + +#[tokio::test] +async fn concurrent_atomic_claims_have_exactly_one_winner() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + let descriptors = root.path().join("descriptors"); + fs::create_dir(&workspace).unwrap(); + fs::create_dir(&descriptors).unwrap(); + let binary = root.path().join("fixture-remote-cli"); + executable( + &binary, + r#"#!/bin/sh +case "$1" in + --version) + echo 'fixture-remote 1.0.0' + ;; + claim-one) + if mkdir claim-lock 2>/dev/null; then + printf '%s\n' '{"outcome":"claimed","bead_id":"work:alpha/1"}' + else + printf '%s\n' '{"outcome":"race_lost","claimed_by":"winner"}' + fi + ;; + get) + printf '%s\n' '{"id":"work:alpha/1","title":"fixture","description":null,"priority":2,"status":"in_progress","assignee":"winner","labels":[],"source_repo":"","dependencies":[],"dependents":[],"comments":[],"created_at":"2026-09-05T00:00:00Z","updated_at":"2026-09-05T00:00:01Z"}' + ;; +esac +"#, + ); + descriptor(&descriptors, &binary, r"^fixture-remote 1\.0\.0"); + let store = open_configured_in(&config(), workspace, None, None, None, &descriptors).unwrap(); + let id = BeadId::from("work:alpha/1"); + + let (first, second) = tokio::join!(store.claim(&id, "worker-a"), store.claim(&id, "worker-b")); + let outcomes = [first.unwrap(), second.unwrap()]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimResult::Claimed(_))) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimResult::RaceLost { .. })) + .count(), + 1 + ); +}