From 64f85fb455a25e898946498cd268be8724382adf Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Thu, 23 Jul 2026 20:14:05 +0300 Subject: [PATCH] feat: first-class Crabbox backend for remote Buzz agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship Run on → Crabbox as a product surface, not a bolted-on script. Desktop - Always show Run on; empty state teaches just install-backend-crabbox - Discovery probes info for friendly names (Crabbox) + descriptions - Enum-aware provider config fields; agent badge + lease id in list - Forced delete calls provider destroy so leases stop billing - Probe race fixed via draft ref (config edits no longer re-probe) Provider (buzz-backend-crabbox v0.4) - Protocol: info, deploy, stop, destroy - Stages full toolchain; env secrets via Crabbox helpers (not argv) - Shell-quoted remote paths; workdir/agent_id validation - Secret redaction in errors; respond_to env forwarding - Rejects loopback relays; just install-backend-crabbox recipe Docs, agent skills, and offline unit tests included. Signed-off-by: Yossi Eliaz --- .agents/skills/buzz-backend-crabbox/SKILL.md | 64 ++ .claude/skills/buzz-backend-crabbox/SKILL.md | 64 ++ .codex/skills/buzz-backend-crabbox/SKILL.md | 64 ++ .goose/skills/buzz-backend-crabbox/SKILL.md | 64 ++ AGENTS.md | 1 + Justfile | 28 + .../src-tauri/src/commands/agent_providers.rs | 19 +- desktop/src-tauri/src/commands/agents.rs | 63 +- .../src-tauri/src/managed_agents/backend.rs | 100 +++ .../agents/lib/managedAgentControlActions.ts | 15 +- .../features/agents/ui/ManagedAgentRow.tsx | 24 +- .../agents/ui/ProviderConfigFields.test.mjs | 40 + .../agents/ui/ProviderConfigFields.tsx | 103 ++- .../features/agents/ui/WhereToRunSection.tsx | 122 ++- .../agents/ui/whereToRunIntent.test.mjs | 29 + .../profile/ui/UserProfilePanelFields.tsx | 6 +- desktop/src/shared/api/types.ts | 4 + docs/backend-providers/crabbox.md | 106 +++ examples/README.md | 10 + examples/buzz-backend-crabbox/README.md | 61 ++ .../buzz-backend-crabbox/buzz-backend-crabbox | 840 ++++++++++++++++++ examples/buzz-backend-crabbox/install.sh | 32 + .../buzz-backend-crabbox/test_provider.py | 301 +++++++ 23 files changed, 2098 insertions(+), 62 deletions(-) create mode 100644 .agents/skills/buzz-backend-crabbox/SKILL.md create mode 100644 .claude/skills/buzz-backend-crabbox/SKILL.md create mode 100644 .codex/skills/buzz-backend-crabbox/SKILL.md create mode 100644 .goose/skills/buzz-backend-crabbox/SKILL.md create mode 100644 desktop/src/features/agents/ui/ProviderConfigFields.test.mjs create mode 100644 docs/backend-providers/crabbox.md create mode 100644 examples/buzz-backend-crabbox/README.md create mode 100755 examples/buzz-backend-crabbox/buzz-backend-crabbox create mode 100755 examples/buzz-backend-crabbox/install.sh create mode 100755 examples/buzz-backend-crabbox/test_provider.py diff --git a/.agents/skills/buzz-backend-crabbox/SKILL.md b/.agents/skills/buzz-backend-crabbox/SKILL.md new file mode 100644 index 00000000000..0e12ed61390 --- /dev/null +++ b/.agents/skills/buzz-backend-crabbox/SKILL.md @@ -0,0 +1,64 @@ +--- +name: buzz-backend-crabbox +description: > + Deploy Buzz managed agents onto Crabbox remote boxes from Desktop or the + install recipe. Use when the user wants remote agent spin-up, Run on Crabbox, + buzz-backend-crabbox, or leased agent compute outside this computer. +version: 1 +--- + +# Buzz ↔ Crabbox backend + +Crabbox is a **Desktop backend provider** for Buzz managed agents — not an LLM +provider and not a substitute for the relay. Buzz still owns identity, keys, +channels, and the agent record. Crabbox only hosts the `buzz-acp` harness on a +remote lease. + +## Product surface (what users see) + +1. **Agents → create agent** +2. **Run on → Crabbox** (appears after the provider is installed on PATH) +3. Optional config: Crabbox cloud provider, machine class, idle timeout, existing lease +4. Deploy → Desktop calls `buzz-backend-crabbox` with the standard agent payload +5. Agent badge shows **Crabbox**; runtime line shows lease id +6. **Shutdown** sends `!shutdown` (Buzz-native soft stop) +7. **Delete agent** calls provider `destroy` → releases the Crabbox lease + +## Install (dev / OSS) + +```bash +just install-backend-crabbox +# or: ./examples/buzz-backend-crabbox/install.sh +brew install openclaw/tap/crabbox +crabbox login --url +crabbox doctor +``` + +Restart Desktop so PATH discovery picks up `~/.local/bin/buzz-backend-crabbox`. + +## Agent / operator rules + +- **Relay must be reachable from the box.** Reject loopback `ws://localhost:…` + unless the user has a tunnel; prefer the community’s real relay URL. +- **Do not put model or crabbox secrets in provider config.** Desktop rejects + secret-shaped config keys. Crabbox auth is `crabbox login`; model keys go in + agent/persona env vars (forwarded via Crabbox env helper). +- **Trust boundary.** The provider binary receives the agent nsec. Only install + `buzz-backend-*` from this repo or a source the owner trusts. +- **Stop path.** Prefer channel `!shutdown` / Desktop Shutdown. Then + `crabbox stop ` so spend stops. There is no protocol `undeploy` in v1. +- **Reuse a warm box.** Set provider config `lease_id` to a slug/`cbx_…` from + `crabbox list` instead of warming a new machine every deploy. + +## Probe + +```bash +echo '{"op":"info","request_id":"1"}' | buzz-backend-crabbox +just test-backend-crabbox +``` + +## Docs + +- `docs/backend-providers/crabbox.md` +- `examples/buzz-backend-crabbox/README.md` +- https://crabbox.sh/ diff --git a/.claude/skills/buzz-backend-crabbox/SKILL.md b/.claude/skills/buzz-backend-crabbox/SKILL.md new file mode 100644 index 00000000000..0e12ed61390 --- /dev/null +++ b/.claude/skills/buzz-backend-crabbox/SKILL.md @@ -0,0 +1,64 @@ +--- +name: buzz-backend-crabbox +description: > + Deploy Buzz managed agents onto Crabbox remote boxes from Desktop or the + install recipe. Use when the user wants remote agent spin-up, Run on Crabbox, + buzz-backend-crabbox, or leased agent compute outside this computer. +version: 1 +--- + +# Buzz ↔ Crabbox backend + +Crabbox is a **Desktop backend provider** for Buzz managed agents — not an LLM +provider and not a substitute for the relay. Buzz still owns identity, keys, +channels, and the agent record. Crabbox only hosts the `buzz-acp` harness on a +remote lease. + +## Product surface (what users see) + +1. **Agents → create agent** +2. **Run on → Crabbox** (appears after the provider is installed on PATH) +3. Optional config: Crabbox cloud provider, machine class, idle timeout, existing lease +4. Deploy → Desktop calls `buzz-backend-crabbox` with the standard agent payload +5. Agent badge shows **Crabbox**; runtime line shows lease id +6. **Shutdown** sends `!shutdown` (Buzz-native soft stop) +7. **Delete agent** calls provider `destroy` → releases the Crabbox lease + +## Install (dev / OSS) + +```bash +just install-backend-crabbox +# or: ./examples/buzz-backend-crabbox/install.sh +brew install openclaw/tap/crabbox +crabbox login --url +crabbox doctor +``` + +Restart Desktop so PATH discovery picks up `~/.local/bin/buzz-backend-crabbox`. + +## Agent / operator rules + +- **Relay must be reachable from the box.** Reject loopback `ws://localhost:…` + unless the user has a tunnel; prefer the community’s real relay URL. +- **Do not put model or crabbox secrets in provider config.** Desktop rejects + secret-shaped config keys. Crabbox auth is `crabbox login`; model keys go in + agent/persona env vars (forwarded via Crabbox env helper). +- **Trust boundary.** The provider binary receives the agent nsec. Only install + `buzz-backend-*` from this repo or a source the owner trusts. +- **Stop path.** Prefer channel `!shutdown` / Desktop Shutdown. Then + `crabbox stop ` so spend stops. There is no protocol `undeploy` in v1. +- **Reuse a warm box.** Set provider config `lease_id` to a slug/`cbx_…` from + `crabbox list` instead of warming a new machine every deploy. + +## Probe + +```bash +echo '{"op":"info","request_id":"1"}' | buzz-backend-crabbox +just test-backend-crabbox +``` + +## Docs + +- `docs/backend-providers/crabbox.md` +- `examples/buzz-backend-crabbox/README.md` +- https://crabbox.sh/ diff --git a/.codex/skills/buzz-backend-crabbox/SKILL.md b/.codex/skills/buzz-backend-crabbox/SKILL.md new file mode 100644 index 00000000000..0e12ed61390 --- /dev/null +++ b/.codex/skills/buzz-backend-crabbox/SKILL.md @@ -0,0 +1,64 @@ +--- +name: buzz-backend-crabbox +description: > + Deploy Buzz managed agents onto Crabbox remote boxes from Desktop or the + install recipe. Use when the user wants remote agent spin-up, Run on Crabbox, + buzz-backend-crabbox, or leased agent compute outside this computer. +version: 1 +--- + +# Buzz ↔ Crabbox backend + +Crabbox is a **Desktop backend provider** for Buzz managed agents — not an LLM +provider and not a substitute for the relay. Buzz still owns identity, keys, +channels, and the agent record. Crabbox only hosts the `buzz-acp` harness on a +remote lease. + +## Product surface (what users see) + +1. **Agents → create agent** +2. **Run on → Crabbox** (appears after the provider is installed on PATH) +3. Optional config: Crabbox cloud provider, machine class, idle timeout, existing lease +4. Deploy → Desktop calls `buzz-backend-crabbox` with the standard agent payload +5. Agent badge shows **Crabbox**; runtime line shows lease id +6. **Shutdown** sends `!shutdown` (Buzz-native soft stop) +7. **Delete agent** calls provider `destroy` → releases the Crabbox lease + +## Install (dev / OSS) + +```bash +just install-backend-crabbox +# or: ./examples/buzz-backend-crabbox/install.sh +brew install openclaw/tap/crabbox +crabbox login --url +crabbox doctor +``` + +Restart Desktop so PATH discovery picks up `~/.local/bin/buzz-backend-crabbox`. + +## Agent / operator rules + +- **Relay must be reachable from the box.** Reject loopback `ws://localhost:…` + unless the user has a tunnel; prefer the community’s real relay URL. +- **Do not put model or crabbox secrets in provider config.** Desktop rejects + secret-shaped config keys. Crabbox auth is `crabbox login`; model keys go in + agent/persona env vars (forwarded via Crabbox env helper). +- **Trust boundary.** The provider binary receives the agent nsec. Only install + `buzz-backend-*` from this repo or a source the owner trusts. +- **Stop path.** Prefer channel `!shutdown` / Desktop Shutdown. Then + `crabbox stop ` so spend stops. There is no protocol `undeploy` in v1. +- **Reuse a warm box.** Set provider config `lease_id` to a slug/`cbx_…` from + `crabbox list` instead of warming a new machine every deploy. + +## Probe + +```bash +echo '{"op":"info","request_id":"1"}' | buzz-backend-crabbox +just test-backend-crabbox +``` + +## Docs + +- `docs/backend-providers/crabbox.md` +- `examples/buzz-backend-crabbox/README.md` +- https://crabbox.sh/ diff --git a/.goose/skills/buzz-backend-crabbox/SKILL.md b/.goose/skills/buzz-backend-crabbox/SKILL.md new file mode 100644 index 00000000000..0e12ed61390 --- /dev/null +++ b/.goose/skills/buzz-backend-crabbox/SKILL.md @@ -0,0 +1,64 @@ +--- +name: buzz-backend-crabbox +description: > + Deploy Buzz managed agents onto Crabbox remote boxes from Desktop or the + install recipe. Use when the user wants remote agent spin-up, Run on Crabbox, + buzz-backend-crabbox, or leased agent compute outside this computer. +version: 1 +--- + +# Buzz ↔ Crabbox backend + +Crabbox is a **Desktop backend provider** for Buzz managed agents — not an LLM +provider and not a substitute for the relay. Buzz still owns identity, keys, +channels, and the agent record. Crabbox only hosts the `buzz-acp` harness on a +remote lease. + +## Product surface (what users see) + +1. **Agents → create agent** +2. **Run on → Crabbox** (appears after the provider is installed on PATH) +3. Optional config: Crabbox cloud provider, machine class, idle timeout, existing lease +4. Deploy → Desktop calls `buzz-backend-crabbox` with the standard agent payload +5. Agent badge shows **Crabbox**; runtime line shows lease id +6. **Shutdown** sends `!shutdown` (Buzz-native soft stop) +7. **Delete agent** calls provider `destroy` → releases the Crabbox lease + +## Install (dev / OSS) + +```bash +just install-backend-crabbox +# or: ./examples/buzz-backend-crabbox/install.sh +brew install openclaw/tap/crabbox +crabbox login --url +crabbox doctor +``` + +Restart Desktop so PATH discovery picks up `~/.local/bin/buzz-backend-crabbox`. + +## Agent / operator rules + +- **Relay must be reachable from the box.** Reject loopback `ws://localhost:…` + unless the user has a tunnel; prefer the community’s real relay URL. +- **Do not put model or crabbox secrets in provider config.** Desktop rejects + secret-shaped config keys. Crabbox auth is `crabbox login`; model keys go in + agent/persona env vars (forwarded via Crabbox env helper). +- **Trust boundary.** The provider binary receives the agent nsec. Only install + `buzz-backend-*` from this repo or a source the owner trusts. +- **Stop path.** Prefer channel `!shutdown` / Desktop Shutdown. Then + `crabbox stop ` so spend stops. There is no protocol `undeploy` in v1. +- **Reuse a warm box.** Set provider config `lease_id` to a slug/`cbx_…` from + `crabbox list` instead of warming a new machine every deploy. + +## Probe + +```bash +echo '{"op":"info","request_id":"1"}' | buzz-backend-crabbox +just test-backend-crabbox +``` + +## Docs + +- `docs/backend-providers/crabbox.md` +- `examples/buzz-backend-crabbox/README.md` +- https://crabbox.sh/ diff --git a/AGENTS.md b/AGENTS.md index c94ba3881e2..28e78ec58ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, | [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR | | [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster | | [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay | +| OSS: [`examples/buzz-backend-crabbox`](examples/buzz-backend-crabbox) · `just install-backend-crabbox` | First-class Desktop **Run on → Crabbox** backend ([docs](docs/backend-providers/crabbox.md)) | ``` block/buzz (source) diff --git a/Justfile b/Justfile index 317541e2f49..039cce422aa 100644 --- a/Justfile +++ b/Justfile @@ -898,6 +898,34 @@ _release-pr lane version: # ─── Agent Harness ──────────────────────────────────────────────────────────── +# Install the Crabbox Desktop backend provider so agents can Run on → Crabbox. +# Builds release agent binaries, puts the provider on PATH (~/.local/bin), and +# prints the Desktop setup steps. Requires the crabbox CLI separately: +# brew install openclaw/tap/crabbox && crabbox login --url +install-backend-crabbox: + #!/usr/bin/env bash + set -euo pipefail + export PATH="{{justfile_directory()}}/bin:$PATH" + echo "→ building buzz-acp / buzz-agent / buzz-cli / buzz-dev-mcp (release)…" + cargo build --release -p buzz-acp -p buzz-agent -p buzz-cli -p buzz-dev-mcp -p git-credential-nostr + release="{{justfile_directory()}}/target/release" + export PATH="$release:$PATH" + "{{justfile_directory()}}/examples/buzz-backend-crabbox/install.sh" + echo + if command -v crabbox >/dev/null 2>&1; then + echo "✓ crabbox CLI: $(command -v crabbox) ($(crabbox --version 2>/dev/null || echo present))" + else + echo "! crabbox CLI not found. Install: brew install openclaw/tap/crabbox" + echo " then: crabbox login --url && crabbox doctor" + fi + echo + echo "Desktop: restart Buzz, create/start an agent, choose Run on → Crabbox." + echo "Docs: docs/backend-providers/crabbox.md" + +# Offline unit tests for the Crabbox backend provider (no live Crabbox required) +test-backend-crabbox: + python3 "{{justfile_directory()}}/examples/buzz-backend-crabbox/test_provider.py" + # Run a goose agent connected to a Buzz relay (foreground) goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY": #!/usr/bin/env bash diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index 178ec0bb6d6..d85ad9d1c76 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,13 +1,24 @@ -use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; +use crate::managed_agents::{ + discover_provider_candidates, invoke_provider, probe_provider_info, BackendProviderInfo, +}; #[tauri::command] pub async fn discover_backend_providers() -> Result, String> { + // Best-effort `info` probe per candidate so the Desktop "Run on" picker can + // show friendly names (e.g. "Crabbox") instead of raw ids. Probe failures + // never drop a candidate — they fall back to the id as the label. tokio::task::spawn_blocking(|| { discover_provider_candidates() .into_iter() - .map(|(id, path)| BackendProviderInfo { - id, - binary_path: path.display().to_string(), + .map(|(id, path)| { + let (name, description, version) = probe_provider_info(&path); + BackendProviderInfo { + id, + binary_path: path.display().to_string(), + name, + description, + version, + } }) .collect() }) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5dee..e31c47f6cae 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -7,8 +7,8 @@ use crate::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, - provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, + provider_deploy, provider_destroy, resolve_provider_binary, save_managed_agents, + start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, @@ -452,8 +452,9 @@ pub(super) async fn start_local_agent_with_preflight( /// spawn_blocking, and persists the result (backend_agent_id or last_error). /// /// Idempotency: calling deploy on an already-deployed agent sends the same payload -/// again. Providers are expected to handle this as an update-in-place or no-op — -/// the protocol does not include an explicit `undeploy` operation (deferred to v2). +/// again. Providers are expected to handle this as an update-in-place or no-op. +/// Forced remote delete invokes provider `destroy` (best-effort) so capacity +/// providers like Crabbox can release the lease. /// /// Returns Ok(()) on success, Err(message) on failure. Either way the record is /// updated and saved before returning. @@ -1271,7 +1272,7 @@ pub async fn delete_managed_agent( // invariant — a buggy or compromised IPC caller cannot silently orphan a live // remote deployment. The frontend sends force_remote_delete: true only after // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + let remote_destroy = if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { if record.backend != BackendKind::Local && record.backend_agent_id.is_some() && !force_remote_delete.unwrap_or(false) @@ -1281,6 +1282,58 @@ pub async fn delete_managed_agent( .to_string(), ); } + // Capture destroy target before we drop the record so forced + // deletes can release provider capacity (lease / VM). + match (&record.backend, &record.backend_agent_id) { + ( + BackendKind::Provider { + id, + config, + }, + Some(agent_id), + ) if force_remote_delete.unwrap_or(false) => Some(( + id.clone(), + config.clone(), + agent_id.clone(), + record.provider_binary_path.clone(), + )), + _ => None, + } + } else { + None + }; + + // Best-effort remote teardown before local removal. Failure does not + // block delete — the user already confirmed orphan risk — but we try + // hard so Crabbox/etc. stop billing when the agent is deleted. + if let Some((provider_id, config, agent_id, cached_path)) = remote_destroy { + let bin_path = cached_path + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .map(|p| p.canonicalize().unwrap_or(p)) + .filter(|canonical| { + discover_provider_candidates().iter().any(|(id, cp)| { + id == &provider_id + && cp.canonicalize().ok().as_ref() == Some(canonical) + }) + }) + .map_or_else(|| resolve_provider_binary(&provider_id), Ok); + match bin_path { + Ok(path) => { + if let Err(e) = provider_destroy(&path, &agent_id, &config) { + eprintln!( + "buzz-desktop: provider destroy failed for {pubkey} \ + (agent_id={agent_id}, provider={provider_id}): {e}" + ); + } + } + Err(e) => { + eprintln!( + "buzz-desktop: provider destroy skipped for {pubkey} \ + (provider={provider_id}): {e}" + ); + } + } } if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 5e7a9cbf776..fb4cc511347 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -375,6 +375,47 @@ pub fn provider_deploy( .ok_or_else(|| "deploy response missing agent_id".to_string()) } +/// Soft-stop a remote agent while keeping provider capacity (e.g. a warm lease). +/// +/// Providers that do not implement `stop` return an error; callers treat that as +/// best-effort. Soft stop is optional because Desktop's primary remote stop is +/// the in-channel `!shutdown` mention the harness already understands. +pub fn provider_stop( + binary: &Path, + agent_id: &str, + provider_config: &serde_json::Value, +) -> Result<(), String> { + let request = serde_json::json!({ + "op": "stop", + "request_id": uuid::Uuid::new_v4().to_string(), + "agent_id": agent_id, + "provider_config": provider_config, + }); + let _ = invoke_provider(binary, &request, Duration::from_secs(120))?; + Ok(()) +} + +/// Tear down remote capacity for a deployed agent (release lease / VM / etc.). +/// +/// Called on forced remote delete so OSS providers like Crabbox do not leave +/// paid boxes running after the Desktop record is gone. Providers that only +/// implement `deploy` may return an error; the delete path logs and continues +/// because the user already confirmed orphan risk. +pub fn provider_destroy( + binary: &Path, + agent_id: &str, + provider_config: &serde_json::Value, +) -> Result<(), String> { + let request = serde_json::json!({ + "op": "destroy", + "request_id": uuid::Uuid::new_v4().to_string(), + "agent_id": agent_id, + "provider_config": provider_config, + }); + let _ = invoke_provider(binary, &request, Duration::from_secs(180))?; + Ok(()) +} + /// Validate provider_config: flat object, scalar values, no secret-like keys. pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> { let obj = config @@ -520,12 +561,71 @@ fn is_executable(path: &Path) -> bool { pub struct BackendProviderInfo { pub id: String, pub binary_path: String, + /// Friendly label from a best-effort `info` probe (`name` field). Falls + /// back to `id` when the probe fails or omits a name so the Desktop + /// "Run on" picker never has to invent presentation strings. + pub name: String, + /// Optional short description from the `info` probe for the create dialog. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional provider protocol version from the `info` probe. + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// Probe a discovered provider for presentation metadata. Never fails the +/// discovery list — a hung or broken provider still appears as `id` so the +/// user can see it and the trust warning still names the binary path. +pub fn probe_provider_info(binary: &Path) -> (String, Option, Option) { + let id_fallback = binary + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_prefix("buzz-backend-")) + .unwrap_or("provider"); + let request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + match invoke_provider(binary, &request, Duration::from_secs(3)) { + Ok(resp) => { + let name = resp + .get("name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(id_fallback) + .to_string(); + let description = resp + .get("description") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let version = resp + .get("version") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + (name, description, version) + } + Err(_) => (id_fallback.to_string(), None, None), + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn probe_provider_info_falls_back_when_binary_missing() { + let (name, description, version) = + probe_provider_info(Path::new("/nonexistent/buzz-backend-demo")); + assert_eq!(name, "demo"); + assert!(description.is_none()); + assert!(version.is_none()); + } + #[test] fn redact_secrets_replaces_nsec() { let s = "key=nsec1abc123def456 other"; diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index cdde263004e..c78ee1f95b1 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -156,6 +156,8 @@ export async function deleteManagedAgentWithRules({ preferredChannelId, relayAgents, }); + const backendLabel = agent.backend.id; + const leaseLabel = agent.backendAgentId; if (channelId) { if (presence === "online" || presence === "away") { @@ -165,9 +167,8 @@ export async function deleteManagedAgentWithRules({ if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( - "Shutdown command sent, but the agent may still be running. " + - "Deleting now removes the local record — the remote deployment " + - "will be orphaned if shutdown hasn't completed. Continue?", + `Shutdown sent to the agent. Delete will also ask ${backendLabel} ` + + `to release remote capacity (${leaseLabel}). Continue?`, ); if (!confirmed) { return { cancelled: true }; @@ -176,8 +177,8 @@ export async function deleteManagedAgentWithRules({ } else { if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( - "This agent is offline but the remote deployment may still exist. " + - "Deleting removes the local management record. Continue?", + `This agent is offline. Delete will remove the local record and ask ` + + `${backendLabel} to release ${leaseLabel}. Continue?`, ); if (!confirmed) { return { cancelled: true }; @@ -187,8 +188,8 @@ export async function deleteManagedAgentWithRules({ } else { if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( - "This agent is deployed but not in any channel. " + - "Deleting will orphan the remote deployment (it will keep running). Continue?", + `This agent is on ${backendLabel} (${leaseLabel}) but not in any channel. ` + + `Delete will remove the local record and try to release remote capacity. Continue?`, ); if (!confirmed) { return { cancelled: true }; diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index f39d96fd264..fb100ea6104 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -56,8 +56,14 @@ export function ManagedAgentRow({ onSelectLogAgent: (pubkey: string | null) => void; }) { const isLocal = agent.backend.type === "local"; + // Prefer a human backend id (e.g. "crabbox") and surface the remote handle + // Desktop got back from deploy (lease slug / provider agent id) when present. const runtimeSource = - agent.backend.type === "provider" ? `Remote (${agent.backend.id})` : null; + agent.backend.type === "provider" + ? agent.backendAgentId + ? `Remote · ${agent.backend.id} · ${agent.backendAgentId}` + : `Remote · ${agent.backend.id}` + : null; const personaLabel = agent.personaId ? (personaLabelsById[agent.personaId] ?? null) : null; @@ -408,9 +414,21 @@ function RuntimeBlock({ } function AgentOriginBadge({ agent }: { agent: ManagedAgent }) { + if (agent.backend.type === "local") { + return Local; + } + // Surface the backend id so Crabbox/Blox/etc. read as product destinations, + // not a generic "Remote" blob. Title-case single-token ids for polish. + const raw = agent.backend.id.trim(); + const label = + raw.length === 0 + ? "Remote" + : raw.includes("-") || raw.includes("_") + ? raw + : raw.charAt(0).toUpperCase() + raw.slice(1); return ( - - {agent.backend.type === "local" ? "Local" : "Remote"} + + {label} ); } diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs new file mode 100644 index 00000000000..fd7d0ab511e --- /dev/null +++ b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { coerceConfigValues } from "./ProviderConfigFields.tsx"; + +test("coerceConfigValues casts integer and boolean schema types", () => { + const schema = { + properties: { + size: { type: "integer" }, + enabled: { type: "boolean" }, + region: { type: "string" }, + }, + }; + assert.deepEqual( + coerceConfigValues( + { size: "3", enabled: "true", region: "us" }, + schema, + ), + { size: 3, enabled: true, region: "us" }, + ); +}); + +test("coerceConfigValues keeps empty enum-style strings", () => { + const schema = { + properties: { + provider: { + type: "string", + enum: ["", "hetzner", "aws"], + }, + }, + }; + assert.deepEqual(coerceConfigValues({ provider: "" }, schema), { + provider: "", + }); +}); + +test("coerceConfigValues without schema returns shallow copy of strings", () => { + const input = { a: "1", b: "true" }; + assert.deepEqual(coerceConfigValues(input, undefined), input); +}); diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.tsx b/desktop/src/features/agents/ui/ProviderConfigFields.tsx index e922dd9c1a3..92ef6a804a1 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.tsx +++ b/desktop/src/features/agents/ui/ProviderConfigFields.tsx @@ -26,6 +26,23 @@ export function coerceConfigValues( return result; } +function enumOptions(prop: Record): { + value: string; + label: string; +}[] { + const raw = prop.enum; + if (!Array.isArray(raw) || raw.length === 0) return []; + const labels = + (prop.enumLabels as Record | undefined) ?? + (prop["x-enumLabels"] as Record | undefined) ?? + {}; + return raw.map((entry) => { + const value = entry == null ? "" : String(entry); + const label = labels[value] ?? (value === "" ? "Default" : value); + return { value, label }; + }); +} + export function ProviderConfigFields({ schema, config, @@ -51,33 +68,67 @@ export function ProviderConfigFields({ return (
- {entries.map(([key, prop]) => ( -
-
+ ); + })}
); } diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index f068eceec82..dbb1af3e210 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -1,13 +1,17 @@ -import { AlertTriangle } from "lucide-react"; +import { AlertTriangle, ExternalLink, Server, Sparkles } from "lucide-react"; import * as React from "react"; import { useBackendProvidersQuery } from "@/features/agents/hooks"; import { probeBackendProvider } from "@/shared/api/tauri"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { ProviderConfigFields } from "./ProviderConfigFields"; import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent"; -/** Optional remote-backend selector. Buzz shared compute is an LLM provider, not a run destination. */ +const CRABBOX_DOCS_URL = "https://crabbox.sh/"; +const CRABBOX_INSTALL_HINT = "just install-backend-crabbox"; + +/** Run destination for a managed agent: this computer or a discovered remote backend. */ export function WhereToRunSection({ draft, isPending, @@ -25,15 +29,23 @@ export function WhereToRunSection({ backendProviders.find((provider) => provider.id === draft.runOn) ?? null, [backendProviders, draft.runOn], ); + const hasRemoteBackends = backendProviders.length > 0; + const selectedBinaryPath = selectedBackendProvider?.binaryPath ?? null; + const selectedProviderId = selectedBackendProvider?.id ?? null; + + // Keep a ref so the probe completion callback always sees the latest draft + // without re-running when the user edits config fields. + const draftRef = React.useRef(draft); + draftRef.current = draft; React.useEffect(() => { - if (!isProviderMode || !selectedBackendProvider) { + if (!isProviderMode || !selectedBinaryPath || !selectedProviderId) { setProbeError(null); return; } let cancelled = false; setProbeError(null); - void probeBackendProvider(selectedBackendProvider.binaryPath) + void probeBackendProvider(selectedBinaryPath) .then((result) => { if (cancelled) return; const defaults: Record = {}; @@ -47,8 +59,11 @@ export function WhereToRunSection({ if (property.default != null) defaults[key] = String(property.default); } + // Only apply probe results if the user is still on this provider. + if (draftRef.current.runOn !== selectedProviderId) return; onDraftChange({ - ...draft, + ...draftRef.current, + runOn: selectedProviderId, probedProvider: result, providerConfig: defaults, }); @@ -61,18 +76,44 @@ export function WhereToRunSection({ return () => { cancelled = true; }; - }, [draft, isProviderMode, onDraftChange, selectedBackendProvider]); + }, [isProviderMode, onDraftChange, selectedBinaryPath, selectedProviderId]); + + // If the selected remote backend disappeared from PATH mid-dialog, snap back. + React.useEffect(() => { + if (isProviderMode && hasRemoteBackends && !selectedBackendProvider) { + onDraftChange(emptyWhereToRunDraft); + } + }, [ + hasRemoteBackends, + isProviderMode, + onDraftChange, + selectedBackendProvider, + ]); - if (backendProviders.length === 0) return null; + const displayName = + draft.probedProvider?.name?.trim() || + selectedBackendProvider?.name?.trim() || + selectedBackendProvider?.id || + "provider"; + const description = + draft.probedProvider?.description?.trim() || + selectedBackendProvider?.description?.trim() || + null; return ( -
+
+

+ Local runs on this computer. Remote backends spin the agent up + elsewhere and keep it connected to your Buzz relay — same identity, + different machine. +

+ {!hasRemoteBackends ? ( +
+ +
+

Want agents on a remote box?

+

+ Install the Crabbox backend once, restart Desktop, and{" "} + Crabbox{" "} + appears here. Buzz still owns the agent identity and relay — + Crabbox only hosts the harness. +

+
+              {CRABBOX_INSTALL_HINT}
+            
+ +
+
+ ) : null} + {isProviderMode && selectedBackendProvider ? (
+ {description ? ( +
+ +
+

{displayName}

+

{description}

+
+
+ ) : null}

- This provider at{" "} + {displayName} ( {selectedBackendProvider.binaryPath} - {" "} - will receive your agent's private key. Only use providers - from trusted sources. + + ) will receive this agent's private key so it can sign as + the agent on your relay. Only use backends you trust. Deleting + the agent asks the backend to release remote capacity.

{probeError ? ( diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 500e9019f28..fab4c75875f 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -59,3 +59,32 @@ test("provider draft resolves with coerced config values", () => { config: { region: "us", size: 3 }, }); }); + +test("crabbox-style enum draft coerces and resolves", () => { + const draft = providerDraft({ + runOn: "crabbox", + probedProvider: { + ok: true, + name: "Crabbox", + config_schema: { + properties: { + provider: { + type: "string", + enum: ["", "local-container", "hetzner"], + }, + idle_timeout: { + type: "string", + enum: ["30m", "4h"], + }, + }, + }, + }, + providerConfig: { provider: "local-container", idle_timeout: "4h" }, + }); + assert.equal(canSubmitWhereToRun(draft), true); + assert.deepEqual(resolveBackendIntent(draft), { + type: "provider", + id: "crabbox", + config: { provider: "local-container", idle_timeout: "4h" }, + }); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 62f4123c950..20190fa8afc 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -363,9 +363,11 @@ export function buildOwnerFields({ } if (managedAgent?.backend.type === "provider") { - const backendLabel = managedAgent.backend.id; + const backendLabel = managedAgent.backendAgentId + ? `${managedAgent.backend.id} · ${managedAgent.backendAgentId}` + : managedAgent.backend.id; fields.push({ - copyValue: backendLabel, + copyValue: managedAgent.backendAgentId ?? managedAgent.backend.id, displayValue: backendLabel, icon: Server, label: "Backend", diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d28f2d0cf19..b5c20e01562 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -420,6 +420,10 @@ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; export type BackendProviderCandidate = { id: string; binaryPath: string; + /** Friendly label from the provider `info` probe; falls back to `id`. */ + name: string; + description?: string | null; + version?: string | null; }; export type BackendProviderProbeResult = { diff --git a/docs/backend-providers/crabbox.md b/docs/backend-providers/crabbox.md new file mode 100644 index 00000000000..ed7bd92af96 --- /dev/null +++ b/docs/backend-providers/crabbox.md @@ -0,0 +1,106 @@ +# Backend provider: Crabbox + +**First-class remote run destination for Buzz managed agents.** + +[Crabbox](https://crabbox.sh) hosts the agent harness on a leased remote box. +Buzz Desktop still owns identity, keys, channels, presence, and the agent +record. Crabbox does not replace the relay or LLM provider settings. + +```text +Create agent → Run on → Crabbox → deploy + │ + ▼ +buzz-backend-crabbox → crabbox warmup / cp / run + │ + ▼ +remote buzz-acp (+ staged tools) ──WS──▶ your Buzz relay +``` + +## Buzz product surface + +| Surface | Behavior | +|---------|----------| +| **Run on** picker | Shows **Crabbox** (friendly name from `info` probe) when `buzz-backend-crabbox` is on PATH | +| Config fields | Crabbox provider, machine class, idle timeout, existing lease, remote workdir | +| Deploy | Desktop → provider `deploy` → warm lease, stage toolchain, start harness | +| Agent list | `Remote · crabbox · ` | +| Shutdown | Desktop **Shutdown** / channel `!shutdown` (Buzz-native) | +| Lease cleanup | `crabbox stop ` (cost control) | + +Install: + +```bash +just install-backend-crabbox +brew install openclaw/tap/crabbox +crabbox login --url +crabbox doctor +# restart Buzz Desktop +``` + +Offline tests: `just test-backend-crabbox`. + +## What deploy does + +1. Warms a Crabbox lease (or reuses `lease_id` from provider config). +2. Stages local Buzz toolchain onto the box: **required** `buzz-acp`; best-effort + `buzz`, `buzz-agent`, `buzz-dev-mcp`, credential helpers, plus the agent’s + `agent_command` / `mcp_command` basenames when resolvable on PATH or in + Desktop/cargo locations. +3. Installs a Crabbox env helper with the agent’s key, relay URL, and runtime + env — **not** on shell argv. +4. Starts `buzz-acp` in the background under that helper. +5. Returns the lease id/slug as `agent_id` for Desktop (`backendAgentId`). + +## Protocol (stdin / stdout JSON) + +| Op | Purpose | +|----|---------| +| `info` | Name (**Crabbox**), version, description, config JSON Schema (enums) | +| `deploy` | Warm/reuse lease, stage binaries, start harness → `agent_id` | +| `stop` | Kill remote harness; keep lease warm | +| `destroy` | Kill harness + `crabbox stop` (Desktop agent delete) | + +Safety: remote shell paths are shell-quoted; workdir/agent_id validated; +error text redacts nsec/API-key shapes; loopback relays rejected. + +Request/response shapes match Desktop’s backend provider contract +(`discover_backend_providers` / `provider_deploy` / `provider_destroy` in +`desktop/src-tauri/src/managed_agents/backend.rs`). + +**Soft stop** is still the in-channel `!shutdown` mention (Buzz-native). +**Hard cleanup** on agent delete calls provider `destroy` so the lease is released. + +## Relay reachability + +The remote box must reach `agent.relay_url`. The provider **rejects** loopback +relay URLs (`localhost`, `127.0.0.1`, `::1`). Point the agent at the community’s +reachable relay, or arrange a tunnel into the box. + +## Security + +- Provider config cannot carry secrets (Desktop validates key names). +- Crabbox broker credentials stay in local Crabbox user config (`crabbox login`). +- Agent env is written to a mode-`0600` temp profile, forwarded with + `--allow-env`, and installed as a remote env helper. +- Prefer short idle timeouts for experiments; stop leases when done. +- Only install `buzz-backend-*` binaries from this repository or a trusted source. + +## Troubleshooting + +| Symptom | Check | +|---------|--------| +| Crabbox missing from Run on | `just install-backend-crabbox`; restart Desktop | +| probe fails | `echo '{"op":"info","request_id":"1"}' \| buzz-backend-crabbox` | +| deploy: crabbox not found | `brew install openclaw/tap/crabbox` | +| deploy: buzz-acp not found | `just install-backend-crabbox` | +| deploy: loopback relay | set a reachable `relay_url` on the agent | +| agent dies immediately | `crabbox ssh --id ` → `tail -n 100 /work/buzz-agent/logs/agent.log` | +| box gone after idle | raise idle timeout or reuse **Existing lease** | + +## Related + +- Example + install: [`examples/buzz-backend-crabbox/`](../../examples/buzz-backend-crabbox/) +- Skill: [`.agents/skills/buzz-backend-crabbox/SKILL.md`](../../.agents/skills/buzz-backend-crabbox/SKILL.md) +- Desktop discovery: `desktop/src-tauri/src/managed_agents/backend.rs` +- Deploy payload: `desktop/src-tauri/src/commands/agents_deploy.rs` +- Crabbox docs: diff --git a/examples/README.md b/examples/README.md index 8649ba360dd..29e51bee6ff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,16 @@ It demonstrates two identity paths: See [`countdown-bot/README.md`](countdown-bot/README.md) for usage. +## `buzz-backend-crabbox/` + +A Desktop **backend provider** that deploys managed agents onto +[Crabbox](https://crabbox.sh) remote boxes. Install as +`~/.local/bin/buzz-backend-crabbox`, then choose **Run on → crabbox** when +creating or starting an agent. + +See [`buzz-backend-crabbox/README.md`](buzz-backend-crabbox/README.md) and +[`docs/backend-providers/crabbox.md`](../docs/backend-providers/crabbox.md). + ## `meadow-core/` A persona-pack example for Buzz agents. diff --git a/examples/buzz-backend-crabbox/README.md b/examples/buzz-backend-crabbox/README.md new file mode 100644 index 00000000000..0de91906254 --- /dev/null +++ b/examples/buzz-backend-crabbox/README.md @@ -0,0 +1,61 @@ +# buzz-backend-crabbox + +First-class **Run on → Crabbox** backend for Buzz Desktop. + +Buzz keeps ownership of the agent (keys, channels, relay). Crabbox hosts the +harness on a remote lease. See the product runbook: +[`docs/backend-providers/crabbox.md`](../../docs/backend-providers/crabbox.md). + +```text +Buzz Desktop ──JSON──▶ buzz-backend-crabbox ──CLI──▶ crabbox lease + │ + stage buzz-acp + tools + ▼ + remote buzz-acp ──▶ Buzz relay +``` + +## One-shot install + +```bash +just install-backend-crabbox +brew install openclaw/tap/crabbox +crabbox login --url +``` + +Restart Desktop. Create an agent → **Run on → Crabbox**. + +## Manual install + +```bash +cargo build --release -p buzz-acp -p buzz-agent -p buzz-cli -p buzz-dev-mcp +export PATH="$PWD/target/release:$PATH" +./examples/buzz-backend-crabbox/install.sh # → ~/.local/bin/buzz-backend-crabbox +``` + +## Protocol + +| Op | Result | +|----|--------| +| `info` | `{ ok, name: "Crabbox", version, description, config_schema }` | +| `deploy` | Warm/reuse lease, stage toolchain, start harness → `{ ok, agent_id }` | +| `stop` | Kill remote harness; keep lease | +| `destroy` | Kill harness + `crabbox stop` (Desktop agent delete) | + +```bash +echo '{"op":"info","request_id":"1"}' | buzz-backend-crabbox | jq . +just test-backend-crabbox +``` + +## Security + +Desktop warns that the provider receives the agent private key. Secrets use +Crabbox env helpers (not argv). Loopback relay URLs are rejected. + +## Lifecycle + +| Action | How | +|--------|-----| +| Start / redeploy | Desktop **Deploy** | +| Soft stop | Desktop **Shutdown** / channel `!shutdown` | +| Delete agent | Desktop delete → provider `destroy` → lease released | +| Manual release | `crabbox stop ` | diff --git a/examples/buzz-backend-crabbox/buzz-backend-crabbox b/examples/buzz-backend-crabbox/buzz-backend-crabbox new file mode 100755 index 00000000000..29db2e6a1a4 --- /dev/null +++ b/examples/buzz-backend-crabbox/buzz-backend-crabbox @@ -0,0 +1,840 @@ +#!/usr/bin/env python3 +"""Buzz Desktop backend: run managed agents on Crabbox leases. + +Install +------- +``just install-backend-crabbox`` (or symlink this file to +``~/.local/bin/buzz-backend-crabbox``). Desktop discovers ``buzz-backend-*`` +on PATH and shows **Crabbox** under **Run on**. + +Protocol (JSON stdin → JSON stdout) +----------------------------------- +info → name, version, description, config_schema +deploy → warm/reuse lease, stage toolchain, start harness → agent_id +stop → kill remote harness, keep lease +destroy → kill harness + ``crabbox stop`` (Desktop agent delete) + +Secrets use Crabbox ``--env-from-profile`` / ``--allow-env`` / ``--env-helper`` +— never shell argv. Loopback relay URLs are rejected. + +See docs/backend-providers/crabbox.md. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +PROVIDER_VERSION = "0.4.0" +PROVIDER_NAME = "Crabbox" +PROVIDER_DESCRIPTION = ( + "Run this agent on a Crabbox remote box (https://crabbox.sh). " + "Buzz keeps identity, keys, and the relay; Crabbox hosts the harness. " + "Shutdown soft-stops the agent; delete releases the lease." +) + +REQUIRED_BINARIES = ("buzz-acp",) +OPTIONAL_BINARIES = ( + "buzz", + "buzz-agent", + "buzz-dev-mcp", + "git-credential-nostr", + "git-sign-nostr", +) + +# Always allowlisted for Crabbox env forwarding when set. +BASE_FORWARDED_ENV = ( + "PATH", + "BUZZ_PRIVATE_KEY", + "BUZZ_RELAY_URL", + "BUZZ_AUTH_TAG", + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_SYSTEM_PROMPT", + "BUZZ_ACP_IDLE_TIMEOUT", + "BUZZ_ACP_MAX_TURN_DURATION", + "BUZZ_ACP_AGENTS", + "BUZZ_ACP_MULTIPLE_EVENT_HANDLING", + "BUZZ_ACP_DEDUP", + "BUZZ_ACP_LAZY_POOL", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_MANAGED_AGENT", +) + +# Cap how far we walk up the filesystem looking for cargo/Desktop binaries. +_MAX_PARENT_WALK = 6 + +CONFIG_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "provider": { + "type": "string", + "title": "Cloud / sandbox provider", + "description": ( + "Where Crabbox leases capacity. Empty = your crabbox default. " + "local-container is free local Docker/Podman." + ), + "default": "", + "enum": [ + "", + "local-container", + "hetzner", + "aws", + "azure", + "gcp", + "digitalocean", + "e2b", + "daytona", + ], + "enumLabels": { + "": "Crabbox default", + "local-container": "Local container (Docker/Podman)", + "hetzner": "Hetzner", + "aws": "AWS", + "azure": "Azure", + "gcp": "Google Cloud", + "digitalocean": "DigitalOcean", + "e2b": "E2B", + "daytona": "Daytona", + }, + }, + "class": { + "type": "string", + "title": "Machine class", + "description": "Crabbox machine class. Empty = default.", + "default": "", + "enum": ["", "fast", "beast"], + "enumLabels": {"": "Default", "fast": "Fast", "beast": "Beast"}, + }, + "idle_timeout": { + "type": "string", + "title": "Idle timeout", + "description": "Release the box after this much idle time (cost guardrail).", + "default": "4h", + "enum": ["30m", "90m", "4h", "12h"], + "enumLabels": { + "30m": "30 minutes", + "90m": "90 minutes", + "4h": "4 hours", + "12h": "12 hours", + }, + }, + "lease_id": { + "type": "string", + "title": "Existing lease", + "description": "Reuse a warm lease id (cbx_…) or slug instead of warming a new box.", + "default": "", + }, + "workdir": { + "type": "string", + "title": "Remote workdir", + "description": "Directory on the box for binaries, logs, and state.", + "default": "/work/buzz-agent", + }, + }, + "required": [], +} + + +# ─── I/O ────────────────────────────────────────────────────────────────────── + + +def main() -> int: + try: + raw = sys.stdin.read() + if not raw.strip(): + return fail("empty stdin; expected one JSON request object") + request = json.loads(raw) + except json.JSONDecodeError as exc: + return fail(f"invalid JSON on stdin: {exc}") + + if not isinstance(request, dict): + return fail("request must be a JSON object") + + try: + op = request.get("op") + if op == "info": + return ok( + { + "name": PROVIDER_NAME, + "version": PROVIDER_VERSION, + "description": PROVIDER_DESCRIPTION, + "config_schema": CONFIG_SCHEMA, + } + ) + if op == "deploy": + return deploy(request) + if op == "stop": + return stop_remote(request, destroy_lease=False) + if op == "destroy": + return stop_remote(request, destroy_lease=True) + return fail( + f"unsupported op: {op!r} (supported: info, deploy, stop, destroy)" + ) + except ProviderError as exc: + return fail(str(exc)) + except Exception as exc: # noqa: BLE001 — always return JSON at the boundary + return fail(f"internal error: {exc}") + + +def ok(payload: dict[str, Any]) -> int: + sys.stdout.write(json.dumps({"ok": True, **payload}, separators=(",", ":"))) + sys.stdout.write("\n") + sys.stdout.flush() + return 0 + + +def fail(message: str) -> int: + sys.stdout.write( + json.dumps( + {"ok": False, "error": redact_secrets(message)}, + separators=(",", ":"), + ) + ) + sys.stdout.write("\n") + sys.stdout.flush() + return 1 + + +class ProviderError(Exception): + """User-facing failure that becomes {ok:false, error:...}.""" + + +def redact_secrets(text: str) -> str: + """Best-effort scrub before Desktop surfaces last_error.""" + out = text + out = re.sub(r"nsec1[0-9a-z]+", "nsec1[REDACTED]", out, flags=re.I) + out = re.sub(r"sprt_tok_[A-Za-z0-9_-]+", "sprt_tok_[REDACTED]", out) + out = re.sub(r"\bsk-[A-Za-z0-9_-]{12,}\b", "sk-[REDACTED]", out) + return out + + +# ─── Deploy ─────────────────────────────────────────────────────────────────── + + +def deploy(request: dict[str, Any]) -> int: + agent = request.get("agent") + if not isinstance(agent, dict): + raise ProviderError("deploy requires agent object") + + provider_config = request.get("provider_config") or {} + if not isinstance(provider_config, dict): + raise ProviderError("provider_config must be an object") + + private_key = str(agent.get("private_key_nsec") or "").strip() + if not private_key: + raise ProviderError("agent.private_key_nsec is required") + + relay_url = str(agent.get("relay_url") or "").strip() + if not relay_url: + raise ProviderError("agent.relay_url is required") + if is_loopback_relay(relay_url): + raise ProviderError( + "agent.relay_url points at loopback; the Crabbox box cannot reach it. " + "Point the agent at a reachable relay URL (or tunnel into the box)." + ) + + crabbox = require_crabbox() + toolchain = resolve_toolchain(agent) + if "buzz-acp" not in toolchain: + raise ProviderError( + "buzz-acp not found on PATH or in Desktop/cargo locations. " + "Run `just install-backend-crabbox`." + ) + + workdir = normalize_workdir(provider_config.get("workdir")) + + with claim_repo() as repo: + lease_id = str(provider_config.get("lease_id") or "").strip() + if not lease_id: + lease_id = warmup_lease(crabbox, agent, provider_config, cwd=repo) + + stage_remote_tree(crabbox, lease_id, workdir, toolchain, cwd=repo) + profile_path = write_env_profile(agent, remote_bin=f"{workdir}/bin") + try: + install_env_helper(crabbox, lease_id, profile_path, cwd=repo) + start_agent(crabbox, lease_id, workdir, cwd=repo) + finally: + try: + profile_path.unlink(missing_ok=True) + except OSError: + pass + + return ok({"agent_id": lease_id}) + + +def stop_remote(request: dict[str, Any], *, destroy_lease: bool) -> int: + agent_id = str(request.get("agent_id") or "").strip() + if not agent_id: + raise ProviderError("agent_id is required") + if not re.fullmatch(r"[A-Za-z0-9._:-]+", agent_id): + raise ProviderError("agent_id contains invalid characters") + + provider_config = request.get("provider_config") or {} + if not isinstance(provider_config, dict): + raise ProviderError("provider_config must be an object") + workdir = normalize_workdir(provider_config.get("workdir")) + + crabbox = require_crabbox() + q_workdir = shlex.quote(workdir) + q_agent = shlex.quote(agent_id) + + with claim_repo() as repo: + kill_cmd = ( + "set +e; " + f"pkill -f {shlex.quote(workdir + '/bin/buzz-acp')} 2>/dev/null; " + "pkill -f buzz-acp 2>/dev/null; " + "true" + ) + try: + run_or_raise( + [ + crabbox, + "run", + "--id", + agent_id, + "--no-sync", + "--shell", + kill_cmd, + ], + timeout=120, + label="stop remote harness", + cwd=repo, + ) + except ProviderError: + if not destroy_lease: + raise + # Lease may already be gone — still try crabbox stop. + + if destroy_lease: + completed = run([crabbox, "stop", agent_id], timeout=180, cwd=repo) + if completed.returncode != 0: + combined = f"{completed.stdout}\n{completed.stderr}".lower() + if not any( + t in combined + for t in ("not found", "no such", "already", "unknown lease") + ): + raise_from_cli( + "crabbox stop failed", + completed.stdout, + completed.stderr, + ) + + _ = (q_workdir, q_agent) # reserved for future structured remote scripts + return ok({"agent_id": agent_id, "destroyed": destroy_lease}) + + +# ─── Crabbox operations ─────────────────────────────────────────────────────── + + +def require_crabbox() -> str: + path = shutil.which("crabbox") + if not path: + raise ProviderError( + "crabbox CLI not found on PATH. Install: brew install openclaw/tap/crabbox" + ) + return path + + +def normalize_workdir(raw: Any) -> str: + workdir = str(raw or "/work/buzz-agent").strip() or "/work/buzz-agent" + if not workdir.startswith("/") or ".." in workdir.split("/"): + raise ProviderError("provider_config.workdir must be an absolute clean path") + if not re.fullmatch(r"/[A-Za-z0-9._/-]+", workdir): + raise ProviderError("provider_config.workdir has invalid characters") + return workdir.rstrip("/") or "/work/buzz-agent" + + +@contextmanager +def claim_repo() -> Iterator[Path]: + """Tiny throwaway git repo — Desktop providers often run with HOME as cwd.""" + with tempfile.TemporaryDirectory(prefix="buzz-crabbox-repo-") as tmp: + repo = Path(tmp) + run_or_raise(["git", "init", "-q"], timeout=30, label="git init", cwd=repo) + (repo / "README.buzz-backend-crabbox").write_text( + "Throwaway Crabbox claim root for Buzz agent deploy.\n", + encoding="utf-8", + ) + run_or_raise( + ["git", "add", "README.buzz-backend-crabbox"], + timeout=30, + label="git add", + cwd=repo, + ) + run_or_raise( + [ + "git", + "-c", + "user.email=buzz-backend-crabbox@localhost", + "-c", + "user.name=buzz-backend-crabbox", + "commit", + "-q", + "-m", + "init", + ], + timeout=30, + label="git commit", + cwd=repo, + ) + yield repo + + +def warmup_lease( + crabbox: str, + agent: dict[str, Any], + provider_config: dict[str, Any], + *, + cwd: Path, +) -> str: + slug = slug_for_agent(agent) + idle = str(provider_config.get("idle_timeout") or "4h").strip() or "4h" + cmd = [ + crabbox, + "warmup", + "--slug", + slug, + "--idle-timeout", + idle, + "--timing-json", + ] + provider = str(provider_config.get("provider") or "").strip() + if provider: + cmd.extend(["--provider", provider]) + machine_class = str(provider_config.get("class") or "").strip() + if machine_class: + cmd.extend(["--class", machine_class]) + + completed = run(cmd, timeout=600, cwd=cwd) + if completed.returncode != 0: + raise_from_cli("crabbox warmup failed", completed.stdout, completed.stderr) + + lease = parse_lease_identity(completed.stdout, completed.stderr, fallback_slug=slug) + if not lease: + raise ProviderError( + "crabbox warmup succeeded but no lease id/slug was found in output" + ) + return lease + + +def stage_remote_tree( + crabbox: str, + lease_id: str, + workdir: str, + toolchain: dict[str, str], + *, + cwd: Path, +) -> None: + q_workdir = shlex.quote(workdir) + run_or_raise( + [ + crabbox, + "run", + "--id", + lease_id, + "--no-sync", + "--shell", + f"mkdir -p {q_workdir}/bin {q_workdir}/logs", + ], + timeout=300, + label="create remote workdir", + cwd=cwd, + ) + + for basename, local_path in toolchain.items(): + run_or_raise( + [ + crabbox, + "cp", + "--id", + lease_id, + local_path, + f"SANDBOX:{workdir}/bin/{basename}", + ], + timeout=300, + label=f"copy {basename}", + cwd=cwd, + ) + + run_or_raise( + [ + crabbox, + "run", + "--id", + lease_id, + "--no-sync", + "--shell", + f"chmod +x {q_workdir}/bin/* 2>/dev/null || true", + ], + timeout=120, + label="chmod remote binaries", + cwd=cwd, + ) + + +def install_env_helper( + crabbox: str, lease_id: str, profile_path: Path, *, cwd: Path +) -> None: + keys = set(BASE_FORWARDED_ENV) | set(read_profile_keys(profile_path)) + allow = ",".join(sorted(keys)) + run_or_raise( + [ + crabbox, + "run", + "--id", + lease_id, + "--no-sync", + "--env-from-profile", + str(profile_path), + "--allow-env", + allow, + "--env-helper", + "buzz-agent", + "--", + "true", + ], + timeout=300, + label="install env helper", + cwd=cwd, + ) + + +def start_agent(crabbox: str, lease_id: str, workdir: str, *, cwd: Path) -> int: + q_workdir = shlex.quote(workdir) + q_bin = shlex.quote(f"{workdir}/bin") + q_log = shlex.quote(f"{workdir}/logs/agent.log") + q_acp = shlex.quote(f"{workdir}/bin/buzz-acp") + remote = ( + "set -euo pipefail; " + "HELPER=''; " + "for c in ./.crabbox/env/buzz-agent " + "/work/crabbox/.crabbox/env/buzz-agent " + "\"$HOME/.crabbox/env/buzz-agent\"; do " + " if [ -x \"$c\" ]; then HELPER=\"$c\"; break; fi; " + "done; " + "if [ -z \"$HELPER\" ]; then " + " echo 'env helper buzz-agent not found' >&2; exit 1; " + "fi; " + f"mkdir -p {q_workdir}/logs; " + f"export PATH={q_bin}:\"$PATH\"; " + f"pkill -f {q_acp} 2>/dev/null || true; " + f"nohup \"$HELPER\" {q_acp} >{q_log} 2>&1 < /dev/null & " + "pid=$!; echo \"BUZZ_BACKEND_CRABBOX_PID=$pid\"; " + "sleep 1; " + "if ! kill -0 \"$pid\" 2>/dev/null; then " + " echo 'agent process exited immediately' >&2; " + f" tail -n 80 {q_log} >&2 || true; " + " exit 1; " + "fi" + ) + run_or_raise( + [crabbox, "run", "--id", lease_id, "--no-sync", "--shell", remote], + timeout=300, + label="start remote agent", + cwd=cwd, + ) + return 0 + + +# ─── Toolchain / env ────────────────────────────────────────────────────────── + + +def resolve_toolchain(agent: dict[str, Any]) -> dict[str, str]: + wanted: list[str] = list(REQUIRED_BINARIES) + wanted.extend(OPTIONAL_BINARIES) + for field in ("agent_command", "mcp_command"): + raw = str(agent.get(field) or "").strip() + if raw: + wanted.append(Path(raw).name) + + seen: set[str] = set() + ordered: list[str] = [] + for name in wanted: + base = Path(name).name + if not base or base in seen: + continue + seen.add(base) + ordered.append(base) + + found: dict[str, str] = {} + for name in ordered: + path = resolve_local_binary(name) + if path: + found[name] = path + return found + + +def resolve_local_binary(name: str) -> str | None: + which = shutil.which(name) + if which: + return which + + home = Path.home() + candidates: list[Path] = [ + home / ".local" / "bin" / name, + home / ".buzz" / "bin" / name, + home / ".buzz-dev" / "bin" / name, + Path("/Applications/Buzz.app/Contents/MacOS") / name, + home / "Applications" / "Buzz.app" / "Contents" / "MacOS" / name, + ] + + # Bounded walk from CWD and this script toward a monorepo root. + for root in (Path.cwd(), Path(__file__).resolve().parent): + parent = root + for _ in range(_MAX_PARENT_WALK): + for rel in ( + Path("target") / "release" / name, + Path("target") / "debug" / name, + ): + candidates.append(parent / rel) + bin_dir = parent / "desktop" / "src-tauri" / "binaries" + if bin_dir.is_dir(): + for entry in bin_dir.iterdir(): + if entry.name == name or entry.name.startswith(f"{name}-"): + candidates.append(entry) + if not parent.parent or parent.parent == parent: + break + parent = parent.parent + + for path in candidates: + try: + if path.is_file() and os.access(path, os.X_OK): + return str(path.resolve()) + except OSError: + continue + return None + + +def write_env_profile(agent: dict[str, Any], *, remote_bin: str | None = None) -> Path: + env = build_agent_env(agent, remote_bin=remote_bin) + fd, name = tempfile.mkstemp(prefix="buzz-crabbox-env-", suffix=".profile") + path = Path(name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + for key, value in env.items(): + if "\n" in value or "\r" in value: + raise ProviderError(f"env value for {key} contains a newline") + handle.write(f"{key}={value}\n") + os.chmod(path, 0o600) + except Exception: + path.unlink(missing_ok=True) + raise + return path + + +def build_agent_env( + agent: dict[str, Any], *, remote_bin: str | None = None +) -> dict[str, str]: + agent_cmd = str(agent.get("agent_command") or "buzz-agent").strip() or "buzz-agent" + mcp_cmd = str(agent.get("mcp_command") or "").strip() + agent_basename = Path(agent_cmd).name + mcp_basename = Path(mcp_cmd).name if mcp_cmd else "" + + env: dict[str, str] = { + "BUZZ_PRIVATE_KEY": str(agent.get("private_key_nsec") or ""), + "BUZZ_RELAY_URL": str(agent.get("relay_url") or ""), + "BUZZ_ACP_AGENT_COMMAND": agent_basename, + "BUZZ_ACP_AGENT_ARGS": join_agent_args(agent.get("agent_args")), + "BUZZ_ACP_MCP_COMMAND": mcp_basename, + "BUZZ_ACP_MULTIPLE_EVENT_HANDLING": "steer", + "BUZZ_ACP_DEDUP": "queue", + "BUZZ_ACP_LAZY_POOL": "true", + "BUZZ_MANAGED_AGENT": "1", + } + if remote_bin: + env["PATH"] = f"{remote_bin}:/usr/local/bin:/usr/bin:/bin" + + if agent.get("auth_tag"): + env["BUZZ_AUTH_TAG"] = str(agent["auth_tag"]) + if agent.get("system_prompt"): + env["BUZZ_ACP_SYSTEM_PROMPT"] = str(agent["system_prompt"]) + if agent.get("idle_timeout_seconds") is not None: + env["BUZZ_ACP_IDLE_TIMEOUT"] = str(agent["idle_timeout_seconds"]) + if agent.get("max_turn_duration_seconds") is not None: + env["BUZZ_ACP_MAX_TURN_DURATION"] = str(agent["max_turn_duration_seconds"]) + if agent.get("parallelism") is not None: + env["BUZZ_ACP_AGENTS"] = str(agent["parallelism"]) + + # Inbound author gate — same fields Desktop serializes in deploy_payload_json. + respond_to = agent.get("respond_to") + if respond_to is not None: + if isinstance(respond_to, dict): + # Serialized enum forms vary; stringify carefully. + mode = respond_to.get("type") or respond_to.get("mode") or respond_to + env["BUZZ_ACP_RESPOND_TO"] = str(mode) + else: + env["BUZZ_ACP_RESPOND_TO"] = str(respond_to) + allowlist = agent.get("respond_to_allowlist") + if isinstance(allowlist, list) and allowlist: + env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"] = ",".join(str(x) for x in allowlist) + + reserved = set(env.keys()) | {"BUZZ_ACP_SETUP_PAYLOAD"} + extra = agent.get("env_vars") or {} + if isinstance(extra, dict): + for key, value in extra.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + if key in reserved: + continue + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + continue + env[key] = value + + return {k: v for k, v in env.items() if v != ""} + + +# ─── Parsing helpers ────────────────────────────────────────────────────────── + + +def slug_for_agent(agent: dict[str, Any]) -> str: + name = str(agent.get("name") or "agent") + base = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-") + base = re.sub(r"-{2,}", "-", base)[:24] or "agent" + return f"buzz-{base}-{uuid.uuid4().hex[:6]}" + + +def parse_lease_identity( + stdout: str, stderr: str, fallback_slug: str +) -> str | None: + combined = "\n".join(p for p in (stdout, stderr) if p) + + for candidate in (stdout.strip(), stderr.strip(), combined.strip()): + if not candidate: + continue + try: + data = json.loads(candidate) + except json.JSONDecodeError: + data = None + if isinstance(data, dict): + found = lease_from_mapping(data) + if found: + return found + for line in candidate.splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(data, dict): + found = lease_from_mapping(data) + if found: + return found + + match = re.search(r"\bcbx_[0-9a-fA-F]{12,}\b", combined) + if match: + return match.group(0) + slug_match = re.search(r"\bslug=([a-z0-9][a-z0-9-]*)", combined) + if slug_match: + return slug_match.group(1) + if fallback_slug: + return fallback_slug + return None + + +def lease_from_mapping(data: dict[str, Any]) -> str | None: + for key in ("id", "lease_id", "leaseId", "leaseID", "slug"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for nest_key in ("lease", "result", "data"): + nested = data.get(nest_key) + if isinstance(nested, dict): + found = lease_from_mapping(nested) + if found: + return found + return None + + +def join_agent_args(raw: Any) -> str: + if raw is None: + return "" + if isinstance(raw, list): + return ",".join(str(item) for item in raw) + return str(raw) + + +def read_profile_keys(path: Path) -> list[str]: + keys: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :] + key, _, _ = line.partition("=") + key = key.strip() + if key: + keys.append(key) + return keys + + +def is_loopback_relay(url: str) -> bool: + lowered = url.lower() + return any( + host in lowered + for host in ( + "://localhost", + "://127.0.0.1", + "://[::1]", + "://0.0.0.0", + ) + ) + + +# ─── Process helpers ────────────────────────────────────────────────────────── + + +def run( + cmd: list[str], timeout: int, *, cwd: Path | None = None +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd) if cwd is not None else None, + ) + + +def run_or_raise( + cmd: list[str], + timeout: int, + label: str, + *, + cwd: Path | None = None, +) -> subprocess.CompletedProcess[str]: + try: + completed = run(cmd, timeout=timeout, cwd=cwd) + except subprocess.TimeoutExpired as exc: + raise ProviderError(f"{label} timed out after {timeout}s") from exc + if completed.returncode != 0: + raise_from_cli(f"{label} failed", completed.stdout, completed.stderr) + return completed + + +def raise_from_cli(message: str, stdout: str, stderr: str) -> None: + parts = [message] + for label, body in (("stdout", stdout), ("stderr", stderr)): + body = (body or "").strip() + if body: + parts.append(f"{label}: {body[:4000]}") + raise ProviderError("\n".join(parts)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/buzz-backend-crabbox/install.sh b/examples/buzz-backend-crabbox/install.sh new file mode 100755 index 00000000000..56717ca218f --- /dev/null +++ b/examples/buzz-backend-crabbox/install.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Install buzz-backend-crabbox onto PATH so Buzz Desktop can discover it. +# +# Desktop scans PATH (plus ~/.local/bin and the app bundle MacOS dir) for +# executables named buzz-backend-*. After install, restart Desktop and pick +# "crabbox" under Run on when creating/starting an agent. +set -euo pipefail + +root="$(cd "$(dirname "$0")" && pwd)" +src="$root/buzz-backend-crabbox" +dest_dir="${BUZZ_BACKEND_BIN_DIR:-$HOME/.local/bin}" +dest="$dest_dir/buzz-backend-crabbox" + +if [[ ! -f "$src" ]]; then + echo "install: missing $src" >&2 + exit 1 +fi + +mkdir -p "$dest_dir" +chmod +x "$src" + +if [[ -e "$dest" || -L "$dest" ]]; then + rm -f "$dest" +fi + +ln -s "$src" "$dest" +echo "installed: $dest -> $src" +echo +echo "Next:" +echo " 1. brew install openclaw/tap/crabbox && crabbox login --url " +echo " 2. ensure buzz-acp (and ideally buzz) are on PATH" +echo " 3. restart Buzz Desktop and choose Run on → crabbox" diff --git a/examples/buzz-backend-crabbox/test_provider.py b/examples/buzz-backend-crabbox/test_provider.py new file mode 100755 index 00000000000..93fec391476 --- /dev/null +++ b/examples/buzz-backend-crabbox/test_provider.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Offline unit tests for buzz-backend-crabbox (no live Crabbox required).""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent +PROVIDER = ROOT / "buzz-backend-crabbox" + + +def load_provider(): + loader = importlib.machinery.SourceFileLoader( + "buzz_backend_crabbox", str(PROVIDER) + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + loader.exec_module(mod) + return mod + + +class ProviderCliTests(unittest.TestCase): + def run_provider(self, payload: dict) -> tuple[int, dict]: + proc = subprocess.run( + [sys.executable, str(PROVIDER)], + input=json.dumps(payload), + capture_output=True, + text=True, + check=False, + ) + self.assertTrue( + proc.stdout.strip(), msg=f"empty stdout; stderr={proc.stderr!r}" + ) + data = json.loads(proc.stdout.strip().splitlines()[-1]) + return proc.returncode, data + + def test_info(self) -> None: + code, data = self.run_provider({"op": "info", "request_id": "t1"}) + self.assertEqual(code, 0) + self.assertTrue(data["ok"]) + self.assertEqual(data["name"], "Crabbox") + self.assertEqual(data["version"], "0.4.0") + self.assertIn("config_schema", data) + self.assertIn("provider", data["config_schema"]["properties"]) + + def test_info_advertises_enum_config(self) -> None: + _, data = self.run_provider({"op": "info", "request_id": "t-enum"}) + props = data["config_schema"]["properties"] + self.assertIn("local-container", props["provider"]["enum"]) + self.assertIn("4h", props["idle_timeout"]["enum"]) + + def test_unknown_op(self) -> None: + code, data = self.run_provider({"op": "explode", "request_id": "t2"}) + self.assertNotEqual(code, 0) + self.assertFalse(data["ok"]) + self.assertIn("unsupported op", data["error"]) + + def test_deploy_rejects_loopback_relay(self) -> None: + code, data = self.run_provider( + { + "op": "deploy", + "request_id": "t3", + "agent": { + "name": "Remote", + "private_key_nsec": "nsec1testonlynotreal", + "relay_url": "ws://localhost:3000", + "agent_command": "buzz-agent", + }, + "provider_config": {}, + } + ) + self.assertNotEqual(code, 0) + self.assertFalse(data["ok"]) + self.assertIn("loopback", data["error"].lower()) + self.assertNotIn("nsec1testonlynotreal", data["error"]) + + def test_deploy_requires_private_key(self) -> None: + code, data = self.run_provider( + { + "op": "deploy", + "request_id": "t4", + "agent": { + "name": "Remote", + "private_key_nsec": "", + "relay_url": "wss://relay.example.com", + }, + "provider_config": {}, + } + ) + self.assertNotEqual(code, 0) + self.assertIn("private_key_nsec", data["error"]) + + +class ProviderUnitTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.mod = load_provider() + + def test_slug_for_agent(self) -> None: + slug = self.mod.slug_for_agent({"name": "My Cool Agent!!!"}) + self.assertTrue(slug.startswith("buzz-my-cool-agent-")) + self.assertLessEqual(len(slug), 40) + + def test_parse_lease_from_human_warmup(self) -> None: + stdout = ( + "leased cbx_0123456789ab slug=swift-crab provider=hetzner " + "server=x type=y ip=1.2.3.4 idle_timeout=30m expires=...\n" + "ready ssh=root@1.2.3.4 :2222 network=public workroot=/work/crabbox\n" + ) + self.assertEqual( + self.mod.parse_lease_identity(stdout, "", fallback_slug="swift-crab"), + "cbx_0123456789ab", + ) + + def test_parse_lease_from_timing_json(self) -> None: + stderr = json.dumps( + {"lease": {"id": "cbx_deadbeefcafe", "slug": "warm-slug"}, "exitCode": 0} + ) + self.assertEqual( + self.mod.parse_lease_identity("", stderr, fallback_slug="x"), + "cbx_deadbeefcafe", + ) + + def test_build_agent_env_merges_and_filters(self) -> None: + env = self.mod.build_agent_env( + { + "private_key_nsec": "nsec1abc", + "relay_url": "wss://relay.example.com", + "agent_command": "/opt/bin/goose", + "agent_args": ["--foo", "bar"], + "system_prompt": "be helpful", + "parallelism": 2, + "auth_tag": "tagvalue", + "respond_to": "owner-only", + "respond_to_allowlist": ["aa" * 32], + "env_vars": { + "ANTHROPIC_API_KEY": "sk-test", + "BUZZ_PRIVATE_KEY": "should-not-override", + "BUZZ_ACP_SETUP_PAYLOAD": "blocked", + "bad-key": "nope", + "EMPTY_SKIP": "", + }, + }, + remote_bin="/work/buzz-agent/bin", + ) + self.assertEqual(env["BUZZ_PRIVATE_KEY"], "nsec1abc") + self.assertEqual(env["BUZZ_ACP_AGENT_COMMAND"], "goose") + self.assertEqual(env["BUZZ_ACP_AGENT_ARGS"], "--foo,bar") + self.assertEqual(env["BUZZ_ACP_AGENTS"], "2") + self.assertEqual(env["BUZZ_AUTH_TAG"], "tagvalue") + self.assertEqual(env["BUZZ_ACP_RESPOND_TO"], "owner-only") + self.assertIn("BUZZ_ACP_RESPOND_TO_ALLOWLIST", env) + self.assertEqual(env["ANTHROPIC_API_KEY"], "sk-test") + self.assertTrue(env["PATH"].startswith("/work/buzz-agent/bin:")) + self.assertNotIn("BUZZ_ACP_SETUP_PAYLOAD", env) + self.assertNotIn("bad-key", env) + + def test_redact_secrets(self) -> None: + raw = "fail nsec1abc123def456 and sk-ant-supersecretkey999" + redacted = self.mod.redact_secrets(raw) + self.assertNotIn("nsec1abc123def456", redacted) + self.assertNotIn("sk-ant-supersecretkey999", redacted) + self.assertIn("[REDACTED]", redacted) + + def test_normalize_workdir_rejects_traversal(self) -> None: + with self.assertRaises(self.mod.ProviderError): + self.mod.normalize_workdir("/work/../etc") + with self.assertRaises(self.mod.ProviderError): + self.mod.normalize_workdir("relative") + self.assertEqual(self.mod.normalize_workdir("/work/buzz-agent/"), "/work/buzz-agent") + + def test_write_env_profile_roundtrip(self) -> None: + path = self.mod.write_env_profile( + { + "private_key_nsec": "nsec1xyz", + "relay_url": "wss://relay.example.com", + "agent_command": "buzz-agent", + }, + remote_bin="/work/buzz-agent/bin", + ) + try: + text = path.read_text(encoding="utf-8") + self.assertIn("BUZZ_PRIVATE_KEY=nsec1xyz", text) + self.assertIn("BUZZ_RELAY_URL=wss://relay.example.com", text) + mode = path.stat().st_mode & 0o777 + self.assertEqual(mode, 0o600) + keys = self.mod.read_profile_keys(path) + self.assertIn("BUZZ_PRIVATE_KEY", keys) + finally: + path.unlink(missing_ok=True) + + def test_stop_and_destroy_mocked(self) -> None: + mod = self.mod + with ( + mock.patch.object(mod.shutil, "which", return_value="/usr/bin/crabbox"), + mock.patch.object(mod, "claim_repo") as claim, + mock.patch.object(mod, "run_or_raise") as run_or_raise, + mock.patch.object(mod, "run") as run_cmd, + ): + # claim_repo is a contextmanager — provide a dummy Path. + from contextlib import contextmanager + from pathlib import Path as P + + @contextmanager + def fake_claim(): + yield P("/tmp/fake-repo") + + claim.side_effect = fake_claim + run_cmd.return_value = mock.Mock( + returncode=0, stdout="stopped\n", stderr="" + ) + + code = mod.stop_remote( + {"agent_id": "cbx_deadbeefcafe", "provider_config": {}}, + destroy_lease=False, + ) + self.assertEqual(code, 0) + run_or_raise.assert_called() + run_cmd.assert_not_called() + + run_or_raise.reset_mock() + code = mod.stop_remote( + {"agent_id": "cbx_deadbeefcafe", "provider_config": {}}, + destroy_lease=True, + ) + self.assertEqual(code, 0) + run_cmd.assert_called() + stop_argv = run_cmd.call_args[0][0] + self.assertEqual(stop_argv[:2], ["/usr/bin/crabbox", "stop"]) + + def test_stop_rejects_bad_agent_id(self) -> None: + with self.assertRaises(self.mod.ProviderError): + self.mod.stop_remote( + {"agent_id": "bad;rm -rf /", "provider_config": {}}, + destroy_lease=False, + ) + + def test_deploy_happy_path_mocked(self) -> None: + mod = self.mod + agent = { + "name": "Remote", + "private_key_nsec": "nsec1abc", + "relay_url": "wss://relay.example.com", + "agent_command": "buzz-agent", + "agent_args": [], + } + request = { + "op": "deploy", + "agent": agent, + "provider_config": {"idle_timeout": "1h", "provider": "local-container"}, + } + + fake_acp = Path(tempfile.mkdtemp()) / "buzz-acp" + fake_acp.write_text("#!/bin/sh\n", encoding="utf-8") + fake_acp.chmod(0o755) + + from contextlib import contextmanager + from pathlib import Path as P + + @contextmanager + def fake_claim(): + yield P("/tmp/fake-repo") + + with ( + mock.patch.object(mod.shutil, "which") as which, + mock.patch.object( + mod, "resolve_toolchain", return_value={"buzz-acp": str(fake_acp)} + ), + mock.patch.object(mod, "warmup_lease", return_value="cbx_mocklease12"), + mock.patch.object(mod, "stage_remote_tree") as stage, + mock.patch.object(mod, "install_env_helper") as helper, + mock.patch.object(mod, "start_agent") as start, + mock.patch.object(mod, "claim_repo", side_effect=fake_claim), + ): + + def which_side(name: str) -> str | None: + if name == "crabbox": + return "/usr/local/bin/crabbox" + return None + + which.side_effect = which_side + code = mod.deploy(request) + self.assertEqual(code, 0) + stage.assert_called_once() + helper.assert_called_once() + start.assert_called_once() + + +if __name__ == "__main__": + os.chmod(PROVIDER, 0o755) + unittest.main()