Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions desktop/src-tauri/src/commands/agent_providers.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<BackendProviderInfo>, String> {
Expand Down Expand Up @@ -39,7 +41,9 @@ pub async fn probe_backend_provider(binary_path: String) -> Result<serde_json::V
"request_id": uuid::Uuid::new_v4().to_string(),
});
tokio::task::spawn_blocking(move || {
invoke_provider(&canonical, &request, std::time::Duration::from_secs(10))
let info = invoke_provider(&canonical, &request, std::time::Duration::from_secs(10))?;
validate_provider_info(&info)?;
Ok(info)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
Expand Down
43 changes: 40 additions & 3 deletions desktop/src-tauri/src/managed_agents/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const STDERR_CAP: usize = 65536;
const STDOUT_CAP: usize = 1_048_576; // 1 MB
const PROVIDER_PROTOCOL_VERSION: u64 = 1;

fn validate_provider_info(info: &serde_json::Value) -> 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())?;
Expand Down Expand Up @@ -45,13 +45,46 @@ 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",
"version",
"protocol_version",
"description",
"config_schema",
"enrollment",
];
if let Some(field) = object
.keys()
Expand Down Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions desktop/src-tauri/src/managed_agents/backend_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/features/agents/ui/WhereToRunSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function WhereToRunSection({
<option value="local">This computer</option>
{backendProviders.map((provider) => (
<option key={provider.id} value={provider.id}>
{provider.id}
{provider.id === "openclaw" ? "OpenClaw" : provider.id}
</option>
))}
</select>
Expand All @@ -119,6 +119,13 @@ export function WhereToRunSection({
Could not probe provider: {probeError}
</p>
) : null}
{draft.probedProvider?.enrollment ? (
<p className="rounded-2xl border border-primary/30 bg-primary/5 px-4 py-3 text-sm">
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.
</p>
) : null}
{draft.probedProvider?.config_schema ? (
<ProviderConfigFields
config={draft.providerConfig}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/shared/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,11 @@ export type BackendProviderProbeResult = {
version?: string;
description?: string;
config_schema?: Record<string, unknown>;
enrollment?: {
operation: "enroll";
one_time: true;
credential_fields?: string[];
};
};

export type RelayMeshConfig = {
Expand Down
94 changes: 94 additions & 0 deletions docs/openclaw-enrollment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# OpenClaw provider enrollment

Buzz Desktop exposes remote run locations through discovered executables named
`buzz-backend-<id>`. 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.