From 182028292adc1b999dd9895387a91efc499cb3f1 Mon Sep 17 00:00:00 2001 From: atlas-maxjb Date: Tue, 4 Aug 2026 19:05:19 -0400 Subject: [PATCH] feat: add OpenClaw enrollment provider flow --- .../src-tauri/src/commands/agent_providers.rs | 8 +- .../src-tauri/src/managed_agents/backend.rs | 43 ++++++++- .../src/managed_agents/backend_tests.rs | 23 +++++ .../features/agents/ui/WhereToRunSection.tsx | 9 +- desktop/src/shared/api/types.ts | 5 + docs/openclaw-enrollment.md | 94 +++++++++++++++++++ 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 docs/openclaw-enrollment.md diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index 178ec0bb6d..6f4d5b0ae5 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,4 +1,6 @@ -use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; +use crate::managed_agents::{ + discover_provider_candidates, invoke_provider, validate_provider_info, BackendProviderInfo, +}; #[tauri::command] pub async fn discover_backend_providers() -> Result, String> { @@ -39,7 +41,9 @@ pub async fn probe_backend_provider(binary_path: String) -> Result Result<(), String> { +pub fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { let object = info .as_object() .ok_or_else(|| "provider info response must be a JSON object".to_string())?; @@ -45,6 +45,38 @@ fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { return Err("provider info response missing object config_schema".to_string()); } + // Providers that can import an agent into an existing remote runtime may + // opt into the one-shot enrollment operation. Keep this capability + // explicit and closed-world: a provider cannot silently change the + // meaning of the secret-bearing deploy request. + if let Some(enrollment) = object.get("enrollment") { + let enrollment = enrollment + .as_object() + .ok_or_else(|| "provider enrollment must be an object".to_string())?; + if enrollment.get("operation").and_then(serde_json::Value::as_str) != Some("enroll") + || enrollment.get("one_time") != Some(&serde_json::Value::Bool(true)) + { + return Err( + "provider enrollment must declare operation: enroll and one_time: true".to_string(), + ); + } + if let Some(fields) = enrollment.get("credential_fields") { + let fields = fields.as_array().ok_or_else(|| { + "provider enrollment credential_fields must be an array".to_string() + })?; + if fields.iter().any(|field| field.as_str().is_none()) { + return Err( + "provider enrollment credential_fields must contain strings".to_string(), + ); + } + } + if enrollment.keys().any(|field| { + !["operation", "one_time", "credential_fields"].contains(&field.as_str()) + }) { + return Err("provider enrollment contains an unknown field".to_string()); + } + } + const FIELDS: &[&str] = &[ "ok", "name", @@ -52,6 +84,7 @@ fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { "protocol_version", "description", "config_schema", + "enrollment", ]; if let Some(field) = object .keys() @@ -519,12 +552,16 @@ pub fn provider_deploy( let info = invoke_provider(&staged, &info_request, Duration::from_secs(10))?; validate_provider_info(&info)?; - let request = serde_json::json!({ - "op": "deploy", + let enrollment = info.get("enrollment").is_some(); + let mut request = serde_json::json!({ + "op": if enrollment { "enroll" } else { "deploy" }, "request_id": uuid::Uuid::new_v4().to_string(), "agent": agent, "provider_config": provider_config, }); + if enrollment { + request["enrollment"] = serde_json::json!({ "version": 1, "mode": "one-time" }); + } let resp = invoke_provider(&staged, &request, Duration::from_secs(600))?; resp["agent_id"] .as_str() diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs index ce1f81466f..5378878ef8 100644 --- a/desktop/src-tauri/src/managed_agents/backend_tests.rs +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -175,6 +175,29 @@ esac"#, ); } +#[cfg(unix)] +#[test] +fn provider_enrollment_uses_explicit_one_time_operation() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let seen = directory.path().join("operation"); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"openclaw","version":"1","protocol_version":1,"description":"enrollment","config_schema":{{}},"enrollment":{{"operation":"enroll","one_time":true,"credential_fields":["private_key_nsec","auth_tag","relay_url"]}}}}' ;; + *\"op\":\"enroll\"*) printf enroll > '{}'; printf '%s\n' '{{"ok":true,"agent_id":"openclaw-1"}}' ;; + *) exit 2 ;; +esac"#, + seen.display() + ); + write_test_provider(&provider, &body); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("staged enrollment"); + assert_eq!(id, "openclaw-1"); + assert_eq!(std::fs::read_to_string(seen).unwrap(), "enroll"); +} + #[cfg(unix)] fn replacement_provider() -> &'static str { r#"#!/bin/sh diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index ee9ec37132..ea3146d534 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -95,7 +95,7 @@ export function WhereToRunSection({ {backendProviders.map((provider) => ( ))} @@ -119,6 +119,13 @@ export function WhereToRunSection({ Could not probe provider: {probeError}

) : null} + {draft.probedProvider?.enrollment ? ( +

+ This provider uses a one-time enrollment import. Buzz Desktop + hands the agent identity to the trusted provider and keeps no + runtime connection to the remote host. +

+ ) : null} {draft.probedProvider?.config_schema ? ( ; + enrollment?: { + operation: "enroll"; + one_time: true; + credential_fields?: string[]; + }; }; export type RelayMeshConfig = { diff --git a/docs/openclaw-enrollment.md b/docs/openclaw-enrollment.md new file mode 100644 index 0000000000..175a75654f --- /dev/null +++ b/docs/openclaw-enrollment.md @@ -0,0 +1,94 @@ +# OpenClaw provider enrollment + +Buzz Desktop exposes remote run locations through discovered executables named +`buzz-backend-`. An OpenClaw integration should install +`buzz-backend-openclaw` on the Desktop machine; it will then appear under +**Run on** without Buzz Desktop becoming a Gateway or runtime proxy. + +## Provider contract + +The provider is a one-process-per-operation stdin/stdout JSON executable. It +must answer `info` first, with protocol version `1`: + +```json +{ + "ok": true, + "name": "openclaw", + "version": "1.0.0", + "protocol_version": 1, + "description": "Enrolls agents with an OpenClaw host", + "config_schema": { + "type": "object", + "properties": { + "host": { "type": "string", "description": "SSH destination, e.g. openclaw@agent-host" }, + "rooms": { "type": "string", "description": "Comma-separated Buzz room UUIDs" }, + "port": { "type": "string", "description": "Optional SSH port" } + }, + "required": ["host", "rooms"] + }, + "enrollment": { + "operation": "enroll", + "one_time": true, + "credential_fields": ["private_key_nsec", "auth_tag", "relay_url"] + } +} +``` + +`config_schema` is persisted in the agent record and is therefore not a place +for credentials. Desktop rejects secret-shaped keys (`key`, `token`, +`credential`, `password`, `secret`) and nested values in provider config. +Host authentication belongs to the provider's normal local trust mechanism +(for example, an existing OpenClaw CLI login or OS credential store). + +After `info`, Desktop invokes the same staged provider binary once with: + +```json +{ + "op": "enroll", + "request_id": "uuid", + "agent": { + "name": "display name", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1...", + "auth_tag": "[\"auth\",\"owner\",\"\",\"signature\"]", + "respond_to": "owner-only", + "respond_to_allowlist": [], + "env_vars": {}, + "launch": { + "command": "openclaw", + "args": ["acp"], + "env": {}, + "policy_env": {}, + "owner_pubkey": "hex" + } + }, + "provider_config": { + "host": "openclaw@agent-host", + "rooms": "ROOM_UUID_1,ROOM_UUID_2", + "port": "22" + }, + "enrollment": { "version": 1, "mode": "one-time" } +} +``` + +The exact managed-agent payload is the source of truth; providers must not +reconstruct identity from `env_vars`. The provider uses SSH only for this +one-time handoff, running `openclaw buzz enroll --stdin` on the remote host. +It imports the identity and room configuration into OpenClaw and returns a +stable host-side identifier: + +```json +{ "ok": true, "agent_id": "openclaw-host-agent-id" } +``` + +Desktop stores only that identifier and the non-secret provider config. It +does not retain a host session, proxy messages, poll the Gateway, or provide +remote logs/stop controls. Subsequent conversation and presence continue over +the Buzz relay. Re-running the action must be idempotent for the same agent +identity; the provider owns reconciliation on the OpenClaw host. +The community relay does not need Tailscale: OpenClaw connects outbound to it +directly. + +On any failure, return `{"ok":false,"error":"..."}` and exit zero. Exit +non-zero means the response is untrusted. Provider diagnostics must never +echo the nsec, auth tag, or environment secrets.