diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..6b35fafce6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -915,6 +915,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-backend-hermes" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "buzz-backend-kubernetes" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index cc1dd0f9df..64efaede07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/buzz-search", "crates/buzz-audit", "crates/buzz-acp", + "crates/buzz-backend-hermes", "crates/buzz-agent", "crates/sprig", "crates/buzz-test-client", diff --git a/crates/buzz-backend-hermes/Cargo.toml b/crates/buzz-backend-hermes/Cargo.toml new file mode 100644 index 0000000000..da4f3b122c --- /dev/null +++ b/crates/buzz-backend-hermes/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "buzz-backend-hermes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/buzz-backend-hermes/README.md b/crates/buzz-backend-hermes/README.md new file mode 100644 index 0000000000..7e50b73150 --- /dev/null +++ b/crates/buzz-backend-hermes/README.md @@ -0,0 +1,42 @@ +# buzz-backend-hermes + +Buzz Desktop provider for an existing, remotely supervised native Hermes +gateway. + +The provider is discovered as `buzz-backend-hermes` and implements the Buzz +provider `info`/`deploy` protocol plus native-Hermes `stop` and authenticated +`cleanup` extensions. Cleanup bootouts the gateway and removes only the +provider-owned Buzz environment block before Desktop deletes the identity. +`deploy` sends the agent payload over SSH to the configured host, writes the +protected Hermes Buzz environment, applies `model.default` and +`model.provider`, and restarts the existing launchd/systemd unit. It never +starts a local ACP process and refuses non-Hermes agent commands. + +Provider configuration is non-secret: + +- `host` and explicit SSH `user` +- `profile`, `supervisor`, and `unit` (plus an optional launchd `plist` path) +- optional Hermes home/profile paths and executable paths +- Buzz channel UUIDs and home channel +- explicit `allowed_users` and `allow_all_users` relay authorization policy + +SSH authentication is ambient (`ssh-agent`/user SSH configuration); no SSH +private key or Nostr secret belongs in `provider_config`. The Nostr private key +and NIP-OA auth tag arrive only in the Desktop deploy payload and are written +remotely with mode `0600`. + +Relay authorization is explicit: `allow_all_users` defaults to false and +must be enabled in the provider configuration when the deployment policy is to +allow relay users, while `require_mention` remains enforced by the generated +configuration. The provider snapshots and restores the profile `.env` and +`config.yaml` if model configuration or supervisor restart fails. + +This provider assumes the remote Hermes gateway and its supervisor already +exist. Deploy and stop operations take an exclusive per-profile remote lock. Configured Hermes home/profile paths must be canonical +(no symlink or dot-segment aliases), and the profile must remain beneath the +Hermes home. On launchd, stop uses `bootout` and deploy bootstraps/enables the plist again, so +KeepAlive cannot silently restart a stopped gateway. +Desktop also enforces one managed identity per `(host, profile, unit)` because +one supervised Hermes gateway is one lifecycle scope. It is therefore a remote +reconfiguration/deployment provider, not an identity importer that pretends an +unowned process is local. diff --git a/crates/buzz-backend-hermes/src/main.rs b/crates/buzz-backend-hermes/src/main.rs new file mode 100644 index 0000000000..b44470cf15 --- /dev/null +++ b/crates/buzz-backend-hermes/src/main.rs @@ -0,0 +1,621 @@ +//! Buzz Desktop provider for an already-supervised native Hermes gateway. +//! +//! This provider deliberately does not launch an ACP process locally. It sends +//! a deployment description over SSH to the configured host, where a small +//! Python transaction updates the Hermes profile and restarts its existing +//! launchd/systemd gateway. SSH credentials are ambient (agent/config), never +//! provider_config fields, and the identity is supplied only in the deploy +//! payload by Buzz Desktop. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_json::{json, Map, Value}; +use std::io::{self, Read, Write}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const PROTOCOL_VERSION: u64 = 1; +const REMOTE_SCRIPT: &str = r###" +import fcntl, json, os, pathlib, stat, subprocess, sys, tempfile, time + +request = json.load(sys.stdin) +agent = request.get("agent") or {} +cfg = request.get("provider_config") or {} +operation = str(request.get("op") or "deploy") + +home = pathlib.Path(str(cfg.get("home") or "~/.hermes")).expanduser() +profile = str(cfg["profile"]) +profile_home = pathlib.Path(str(cfg.get("profile_home") or (home if profile in ("", "default") else home / "profiles" / profile))).expanduser() +try: + home_root = home.resolve() + profile_root = profile_home.resolve() + if home != home_root or profile_home != profile_root: + raise RuntimeError("Hermes home and profile_home must not contain symlinks or dot segments") + profile_root.relative_to(home_root) +except ValueError: + raise RuntimeError("profile_home must be inside Hermes home") +profile_home = profile_root + +def assert_secure_profile(): + try: + current = os.lstat(profile_home) + except FileNotFoundError: + raise RuntimeError("Hermes profile_home does not exist") + if not stat.S_ISDIR(current.st_mode): + raise RuntimeError("Hermes profile_home is not a directory") + if current.st_uid != os.getuid() or (stat.S_IMODE(current.st_mode) & 0o022): + raise RuntimeError("Hermes profile_home ownership or permissions are unsafe") + cursor = pathlib.Path(profile_home.anchor) + for component in profile_home.parts[1:]: + cursor /= component + parent = os.lstat(cursor) + if stat.S_ISLNK(parent.st_mode) or not stat.S_ISDIR(parent.st_mode): + raise RuntimeError("Hermes profile path contains an unsafe component") + if parent.st_uid not in (0, os.getuid()) or (stat.S_IMODE(parent.st_mode) & 0o022): + raise RuntimeError("Hermes profile parent ownership or permissions are unsafe") + if (current.st_dev, current.st_ino) != profile_identity: + raise RuntimeError("Hermes profile path changed during operation") + +profile_identity = (profile_root.stat().st_dev, profile_root.stat().st_ino) +assert_secure_profile() +lock_path = profile_home / ".buzz-backend-hermes.lock" +lock_fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) +os.fchmod(lock_fd, 0o600) +lock_file = os.fdopen(lock_fd, "a+") +fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) +provider_marker_start = "# BEGIN BUZZ BACKEND HERMES\n" +provider_marker_end = "# END BUZZ BACKEND HERMES" + +def read_secure_file(path): + assert_secure_profile() + try: + file_info = os.lstat(path) + except FileNotFoundError: + return None + if stat.S_ISLNK(file_info.st_mode) or file_info.st_uid != os.getuid() or (stat.S_IMODE(file_info.st_mode) & 0o022): + raise RuntimeError(f"{path.name} ownership or permissions are unsafe") + try: + fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + except FileNotFoundError: + return None + with os.fdopen(fd, "rb") as stream: + return stream.read() + +def secure_file_mode(path, default=0o600): + try: + return stat.S_IMODE(os.lstat(path).st_mode) + except FileNotFoundError: + return default + +def remove_provider_blocks(content): + while provider_marker_start in content and provider_marker_end in content: + prefix, marked = content.split(provider_marker_start, 1) + _, suffix = marked.split(provider_marker_end, 1) + content = prefix + suffix + return content + +supervisor = str(cfg["supervisor"]) +unit = str(cfg["unit"]) +if operation in ("stop", "cleanup"): + if supervisor == "systemd": + result = subprocess.run(["systemctl", "--user", "stop", unit], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode: + raise RuntimeError("Hermes gateway stop failed") + elif supervisor == "launchd": + target = f"gui/{os.getuid()}/{unit}" + result = subprocess.run(["launchctl", "bootout", target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode: + status = subprocess.run(["launchctl", "print", target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if status.returncode == 0: + raise RuntimeError("Hermes launchd gateway bootout failed") + + def launchd_has_pid(): + status = subprocess.run(["launchctl", "print", target], capture_output=True, text=True) + return status.returncode == 0 and any( + line.strip().startswith("pid = ") for line in status.stdout.splitlines() + ) + + deadline = time.monotonic() + 10 + while launchd_has_pid() and time.monotonic() < deadline: + time.sleep(0.2) + if launchd_has_pid(): + raise RuntimeError("Hermes launchd gateway did not stop") + else: + raise RuntimeError("unsupported supervisor") + if operation == "stop": + print(json.dumps({"ok": True, "agent_id": f"ssh://{cfg['host']}/{profile}"}, separators=(",", ":"))) + raise SystemExit(0) + + env_path = profile_home / ".env" + old_bytes = read_secure_file(env_path) + if old_bytes is not None: + old = old_bytes.decode("utf-8", "surrogateescape") + cleaned = remove_provider_blocks(old) + if cleaned != old: + assert_secure_profile() + fd, tmp_name = tempfile.mkstemp(prefix=".env.", dir=str(profile_home)) + os.chmod(tmp_name, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(cleaned.encode("utf-8", "surrogateescape")) + stream.flush(); os.fsync(stream.fileno()) + assert_secure_profile() + os.replace(tmp_name, env_path) + os.chmod(env_path, 0o600) + print(json.dumps({"ok": True, "agent_id": f"ssh://{cfg['host']}/{profile}"}, separators=(",", ":"))) + raise SystemExit(0) +if operation != "deploy": + raise RuntimeError("unsupported provider operation") + +private_key = str(agent.get("private_key_nsec") or "").strip() +auth_tag = str(agent.get("auth_tag") or "").strip() +relay_url = str(agent.get("relay_url") or "").strip() +if not private_key or not auth_tag or not relay_url: + raise RuntimeError("identity payload is incomplete") +if str(agent.get("agent_command") or "").strip() != "hermes": + raise RuntimeError("hermes provider refuses a non-Hermes agent command") +if any(any(char in value for char in ("\r", "\n")) for value in (private_key, auth_tag, relay_url)): + raise RuntimeError("identity payload contains a newline") + +def dotenv_value(name, value): + if any(char in value for char in ("\x00", "\r", "\n")): + raise RuntimeError(f"{name} contains a newline") + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + +channels = str(cfg.get("channels") or "a492f811-492b-5d55-b03f-81f9ff6107ea") +home_channel = str(cfg.get("home_channel") or channels.split(",", 1)[0]) +cli_path = str(cfg.get("cli_path") or "buzz") +allowed_users = str(cfg.get("allowed_users") or "") +allow_all_users = cfg.get("allow_all_users") is True +env_path = profile_home / ".env" +old_env_bytes = read_secure_file(env_path) +old_env_mode = secure_file_mode(env_path) +old = old_env_bytes.decode("utf-8", "surrogateescape") if old_env_bytes is not None else "" +for marker_start, marker_end in ( + (provider_marker_start, provider_marker_end), + ("# BEGIN RACKTAQ HERMES BUZZ\n", "# END RACKTAQ HERMES BUZZ"), +): + while marker_start in old and marker_end in old: + prefix, marked = old.split(marker_start, 1) + _, suffix = marked.split(marker_end, 1) + old = prefix + suffix +old = old.replace(provider_marker_end, "") +old = old.replace("# END RACKTAQ HERMES BUZZ", "") +block = provider_marker_start + "\n".join([ + f"BUZZ_PRIVATE_KEY={dotenv_value('private_key', private_key)}", + f"BUZZ_AUTH_TAG={dotenv_value('auth_tag', auth_tag)}", + f"BUZZ_RELAY_URL={dotenv_value('relay_url', relay_url)}", + "BUZZ_TRANSPORT=websocket", + f"BUZZ_CHANNELS={dotenv_value('channels', channels)}", + f"BUZZ_HOME_CHANNEL={dotenv_value('home_channel', home_channel)}", + f"BUZZ_ALLOWED_USERS={dotenv_value('allowed_users', allowed_users)}", + f"BUZZ_ALLOW_ALL_USERS={'true' if allow_all_users else 'false'}", + "BUZZ_REQUIRE_MENTION=true", + f"BUZZ_CLI_PATH={dotenv_value('cli_path', cli_path)}", + provider_marker_end, +]) + "\n" +config_path = profile_home / "config.yaml" +old_config_bytes = read_secure_file(config_path) +old_config_mode = secure_file_mode(config_path) + +def restore_file(path, data, mode): + assert_secure_profile() + if data is None: + try: + path.unlink() + except FileNotFoundError: + pass + return + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent)) + os.chmod(tmp_name, mode) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + stream.flush(); os.fsync(stream.fileno()) + assert_secure_profile() + os.replace(tmp_name, path) + os.chmod(path, mode) + +def restart_supervisor(): + if supervisor == "systemd": + command = ["systemctl", "--user", "restart", unit] + elif supervisor == "launchd": + target = f"gui/{os.getuid()}/{unit}" + enabled = subprocess.run(["launchctl", "enable", target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if enabled.returncode: + raise RuntimeError("Hermes launchd gateway enable failed") + if subprocess.run(["launchctl", "print", target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + plist = pathlib.Path(str(cfg.get("plist") or f"~/Library/LaunchAgents/{unit}.plist")).expanduser() + bootstrapped = subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if bootstrapped.returncode: + raise RuntimeError("Hermes launchd gateway bootstrap failed") + command = ["launchctl", "kickstart", "-k", target] + else: + raise RuntimeError("unsupported supervisor") + return subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +hermes = str(cfg.get("program") or "hermes") +hermes_env = {**os.environ, "HOME": str(home), "HERMES_HOME": str(profile_home)} +model = str(agent.get("model") or "").strip() +provider = str(agent.get("provider") or "").strip() +if not model or not provider: + raise RuntimeError("model and provider are required") +restart_attempted = False +try: + assert_secure_profile() + fd, tmp_name = tempfile.mkstemp(prefix=".env.", dir=str(profile_home)) + os.chmod(tmp_name, 0o600) + with os.fdopen(fd, "wb") as stream: + prefix = old.rstrip() + stream.write(((prefix + "\n" if prefix else "") + block).encode("utf-8", "surrogateescape")) + stream.flush(); os.fsync(stream.fileno()) + assert_secure_profile() + os.replace(tmp_name, env_path) + os.chmod(env_path, 0o600) + + for key, value in (("model.default", model), ("model.provider", provider)): + if value: + assert_secure_profile() + result = subprocess.run([hermes, "config", "set", key, value], env=hermes_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode: + raise RuntimeError(f"Hermes config update failed for {key}") + + assert_secure_profile() + restart_attempted = True + result = restart_supervisor() + if result.returncode: + raise RuntimeError("Hermes gateway restart failed") +except Exception: + restore_file(env_path, old_env_bytes, old_env_mode) + restore_file(config_path, old_config_bytes, old_config_mode) + if restart_attempted: + try: + restart_supervisor() + except Exception: + pass + raise + +print(json.dumps({"ok": True, "agent_id": f"ssh://{cfg['host']}/{profile}"}, separators=(",", ":"))) +"###; + +fn main() { + let mut input = String::new(); + if io::stdin().read_to_string(&mut input).is_err() { + emit_error("could not read provider request"); + return; + } + let request: Value = match serde_json::from_str(&input) { + Ok(value) => value, + Err(_) => { + emit_error("invalid JSON request"); + return; + } + }; + + match request.get("op").and_then(Value::as_str) { + Some("info") => emit_json(info_response()), + Some("deploy") => match deploy(&request, "deploy") { + Ok(response) => emit_json(response), + Err(error) => emit_error(&error), + }, + Some("stop") => match deploy(&request, "stop") { + Ok(response) => emit_json(response), + Err(error) => emit_error(&error), + }, + Some("cleanup") => match deploy(&request, "cleanup") { + Ok(response) => emit_json(response), + Err(error) => emit_error(&error), + }, + _ => emit_error("unsupported provider operation"), + } +} + +fn info_response() -> Value { + json!({ + "ok": true, + "name": "Native Hermes over SSH", + "version": env!("CARGO_PKG_VERSION"), + "protocol_version": PROTOCOL_VERSION, + "description": "Updates an existing launchd/systemd Hermes gateway over SSH; never launches a local ACP runtime.", + "config_schema": { + "type": "object", + "required": ["host", "user", "profile", "supervisor", "unit"], + "properties": { + "host": {"type": "string", "title": "Remote host"}, + "user": {"type": "string", "title": "SSH user"}, + "profile": {"type": "string", "title": "Hermes profile"}, + "supervisor": {"type": "string", "enum": ["launchd", "systemd"], "title": "Supervisor"}, + "unit": {"type": "string", "title": "Supervisor unit/label"}, + "plist": {"type": "string", "title": "launchd plist path"}, + "home": {"type": "string", "default": "~/.hermes", "title": "Hermes home"}, + "profile_home": {"type": "string", "title": "Profile home override"}, + "program": {"type": "string", "default": "hermes", "title": "Hermes executable"}, + "cli_path": {"type": "string", "default": "buzz", "title": "Buzz CLI path"}, + "channels": {"type": "string", "default": "a492f811-492b-5d55-b03f-81f9ff6107ea", "title": "Buzz channel UUIDs"}, + "home_channel": {"type": "string", "default": "a492f811-492b-5d55-b03f-81f9ff6107ea", "title": "Buzz home channel"}, + "allowed_users": {"type": "string", "default": "", "title": "Allowed relay users"}, + "allow_all_users": {"type": "boolean", "default": false, "title": "Allow all relay users"} + } + } + }) +} + +fn deploy(request: &Value, operation: &str) -> Result { + let cfg = request + .get("provider_config") + .and_then(Value::as_object) + .ok_or_else(|| "provider_config must be an object".to_string())?; + let host = required_string(cfg, "host")?; + let profile = required_string(cfg, "profile")?; + let supervisor = required_string(cfg, "supervisor")?; + let unit = required_string(cfg, "unit")?; + let user = required_string(cfg, "user")?; + if !["launchd", "systemd"].contains(&supervisor.as_str()) { + return Err("supervisor must be launchd or systemd".to_string()); + } + validate_ssh_component("host", &host, ":%[]")?; + if profile == "." || profile == ".." { + return Err("provider_config.profile contains unsafe characters".to_string()); + } + validate_ssh_component("profile", &profile, "")?; + validate_ssh_component("user", &user, "")?; + for (field, value) in [("profile", &profile), ("unit", &unit)] { + if value.is_empty() + || value.starts_with('-') + || value.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err(format!( + "provider_config.{field} contains unsafe characters" + )); + } + } + let empty_agent = Value::Object(Map::new()); + let agent = request.get("agent").unwrap_or(&empty_agent); + if operation == "deploy" { + if agent + .get("private_key_nsec") + .and_then(Value::as_str) + .unwrap_or("") + .is_empty() + { + return Err("agent identity is missing".to_string()); + } + if agent + .get("auth_tag") + .and_then(Value::as_str) + .unwrap_or("") + .is_empty() + { + return Err("agent auth tag is missing".to_string()); + } + if agent + .get("relay_url") + .and_then(Value::as_str) + .unwrap_or("") + .is_empty() + { + return Err("agent relay URL is missing".to_string()); + } + for field in ["model", "provider"] { + if agent + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return Err(format!("agent {field} is missing")); + } + } + } + + let mut remote_config = cfg.clone(); + remote_config.insert("host".to_string(), Value::String(host.clone())); + remote_config.insert("profile".to_string(), Value::String(profile.clone())); + remote_config.insert("supervisor".to_string(), Value::String(supervisor)); + remote_config.insert("unit".to_string(), Value::String(unit)); + remote_config.insert("user".to_string(), Value::String(user.clone())); + let mut remote_request = json!({ + "op": operation, + "provider_config": remote_config, + }); + if operation == "deploy" { + remote_request["agent"] = agent.clone(); + } + run_ssh(&host, Some(&user), &remote_request) +} + +fn run_ssh(host: &str, user: Option<&str>, request: &Value) -> Result { + let target = match user.filter(|value| !value.is_empty()) { + Some(user) => format!("{user}@{host}"), + None => host.to_string(), + }; + let mut command = Command::new("ssh"); + let encoded_script = STANDARD.encode(REMOTE_SCRIPT); + let remote_command = + format!("python3 -c 'import base64;exec(base64.b64decode(\"{encoded_script}\"))'"); + command.args([ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=5", + "-o", + "ServerAliveCountMax=2", + "--", + &target, + &remote_command, + ]); + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "could not start ssh".to_string())?; + let body = + serde_json::to_vec(request).map_err(|_| "could not encode remote request".to_string())?; + child + .stdin + .take() + .ok_or_else(|| "ssh stdin unavailable".to_string())? + .write_all(&body) + .map_err(|_| "could not send remote request".to_string())?; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("remote Hermes deployment timed out".to_string()); + } + Err(_) => return Err("ssh execution failed".to_string()), + } + } + let output = child + .wait_with_output() + .map_err(|_| "ssh execution failed".to_string())?; + if !output.status.success() { + return Err("remote Hermes deployment failed".to_string()); + } + let response: Value = serde_json::from_slice(&output.stdout) + .map_err(|_| "remote Hermes returned invalid provider output".to_string())?; + if response.get("ok") != Some(&Value::Bool(true)) { + return Err("remote Hermes deployment was rejected".to_string()); + } + Ok(response) +} + +fn validate_ssh_component(field: &str, value: &str, extra: &str) -> Result<(), String> { + if value.is_empty() + || value.starts_with('-') + || value.chars().any(|character| { + character.is_control() + || character.is_whitespace() + || !(character.is_ascii_alphanumeric() + || ".-_".contains(character) + || extra.contains(character)) + }) + { + return Err(format!( + "provider_config.{field} contains unsafe characters" + )); + } + Ok(()) +} + +fn required_string(config: &Map, key: &str) -> Result { + config + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("provider_config.{key} is required")) +} + +fn emit_json(value: Value) { + println!( + "{}", + serde_json::to_string(&value).unwrap_or_else(|_| "{\"ok\":false}".to_string()) + ); +} + +fn emit_error(message: &str) { + emit_json(json!({"ok": false, "error": message})); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_schema_has_required_non_secret_fields() { + let response = info_response(); + let properties = response["config_schema"]["properties"].as_object().unwrap(); + assert!(properties.contains_key("host")); + assert!(properties.contains_key("profile")); + assert!(properties.contains_key("allow_all_users")); + assert!(!properties + .keys() + .any(|key| key.contains("key") || key.contains("secret"))); + } + + #[test] + fn missing_provider_config_is_rejected_before_ssh() { + let error = deploy(&json!({"op": "deploy", "agent": {}}), "deploy").unwrap_err(); + assert!(error.contains("provider_config")); + } + + #[test] + fn unsafe_ssh_target_is_rejected() { + let error = deploy( + &json!({ + "op": "stop", + "provider_config": { + "host": "-oProxyCommand=touch /tmp/pwned", + "user": "karsten", + "profile": "default", + "supervisor": "launchd", + "unit": "ai.hermes.gateway" + } + }), + "stop", + ) + .unwrap_err(); + assert!(error.contains("host") && error.contains("unsafe")); + } + + #[test] + fn unsafe_profile_is_rejected() { + let error = deploy( + &json!({ + "op": "stop", + "provider_config": { + "host": "example", + "user": "karsten", + "profile": "../.ssh", + "supervisor": "launchd", + "unit": "ai.hermes.gateway" + } + }), + "stop", + ) + .unwrap_err(); + assert!(error.contains("profile") && error.contains("unsafe")); + } + + #[test] + fn deploy_requires_model_and_provider() { + let error = deploy( + &json!({ + "op": "deploy", + "agent": { + "private_key_nsec": "nsec1x", + "auth_tag": "[\"auth\"]", + "relay_url": "wss://relay" + }, + "provider_config": { + "host": "example", + "user": "karsten", + "profile": "default", + "supervisor": "launchd", + "unit": "ai.hermes.gateway" + } + }), + "deploy", + ) + .unwrap_err(); + assert!(error.contains("model")); + } + + #[test] + fn unsafe_unit_is_rejected() { + let error = deploy(&json!({ + "op": "deploy", + "agent": {"private_key_nsec": "nsec1x", "auth_tag": "[\"auth\"]", "relay_url": "wss://relay"}, + "provider_config": {"host": "example", "user": "karsten", "profile": "default", "supervisor": "launchd", "unit": "bad unit"} + }), "deploy").unwrap_err(); + assert!(error.contains("unsafe")); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b114b0474..d8c98e69a8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,10 +6,12 @@ use crate::{ managed_agents::{ 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, normalize_agent_args, provider_deploy, + load_teams, managed_agent_avatar_url, normalize_agent_args, provider_cleanup, + provider_deploy, provider_stop, 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, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, + hermes_provider_lifecycle_scope, hermes_provider_scope_owner, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, @@ -25,6 +27,33 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } +fn hermes_provider_scope_is_in_use( + records: &[ManagedAgentRecord], + backend: &BackendKind, +) -> bool { + let Some(scope) = hermes_provider_lifecycle_scope(backend) else { + return false; + }; + records + .iter() + .filter_map(|record| hermes_provider_lifecycle_scope(&record.backend)) + .any(|other_scope| other_scope == scope) +} + +fn hermes_provider_scope_has_other(records: &[ManagedAgentRecord], pubkey: &str) -> bool { + let Some(target) = records.iter().find(|record| record.pubkey == pubkey) else { + return false; + }; + let Some(scope) = hermes_provider_lifecycle_scope(&target.backend) else { + return false; + }; + records + .iter() + .filter(|record| record.pubkey != pubkey) + .filter_map(|record| hermes_provider_lifecycle_scope(&record.backend)) + .any(|other_scope| other_scope == scope) +} + /// Retain a freshly authored managed-agent event in the local store, flagged /// for relay sync. MUST be called inside the `managed_agents_store_lock`-held /// body after `save_managed_agents`, NEVER across an `.await`: it acquires @@ -631,6 +660,11 @@ pub async fn create_managed_agent( } let keys = Keys::generate(); let pubkey = keys.public_key().to_hex(); + if hermes_provider_scope_is_in_use(&records, &input.backend) { + return Err( + "a managed identity already owns this Hermes host/profile/unit scope".to_string(), + ); + } if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); } @@ -702,6 +736,13 @@ pub async fn create_managed_agent( if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); } + // Re-check under the mutation lock so concurrent creates cannot both + // pass the earlier preflight and claim one Hermes lifecycle scope. + if hermes_provider_scope_is_in_use(&records, &input.backend) { + return Err( + "a managed identity already owns this Hermes host/profile/unit scope".to_string(), + ); + } // Provider config was already validated in Pre-Phase 2; cache the discovered binary path for deploy_to_provider. let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend { // Use resolve_provider_binary (discovered candidates only). @@ -1239,18 +1280,47 @@ pub async fn stop_managed_agent( state.clear_agent_session_caches(pubkey); } + let backend = records + .iter() + .find(|record| record.pubkey == pubkey) + .map(|record| record.backend.clone()) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if let Some(owner) = hermes_provider_scope_owner(&records, &pubkey) { + if owner != pubkey { + return Err(format!( + "this Hermes host/profile/unit scope is owned by managed identity {owner}" + )); + } + } + { let record = find_managed_agent_mut(&mut records, &pubkey)?; - // Remote agents are stopped via !shutdown @mention from the frontend, - // not via this backend command. Reject the call. - if record.backend != BackendKind::Local { - return Err( - "remote agents are stopped via !shutdown message, not this command".to_string(), - ); + match backend { + BackendKind::Local => { + // Pair-scoped: stops only the active workspace's pair; delete and + // the config-restart flows still drain every pair. + stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; + } + BackendKind::Provider { id, config } => { + if id != "hermes" { + return Err( + "remote agents are stopped via !shutdown message, not this command" + .to_string(), + ); + } + let binary = resolve_provider_binary(&id)?; + if let Err(error) = provider_stop(&binary, &config) { + record.last_error = Some(error.clone()); + record.updated_at = now_iso(); + save_managed_agents(&app, &records)?; + return Err(error); + } + record.backend_agent_id = None; + record.last_stopped_at = Some(now_iso()); + record.last_error = None; + record.updated_at = now_iso(); + } } - // Pair-scoped: stops only the active workspace's pair; delete and - // the config-restart flows still drain every pair. - stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; } save_managed_agents(&app, &records)?; let record = records @@ -1321,8 +1391,55 @@ pub async fn delete_managed_agent( } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + let hermes_owner = hermes_provider_scope_owner(&records, &pubkey); + let hermes_scope_has_other = hermes_provider_scope_has_other(&records, &pubkey); + if hermes_owner.as_deref() == Some(pubkey.as_str()) && hermes_scope_has_other { + return Err( + "delete duplicate Hermes managed records for this host/profile/unit before deleting the lifecycle owner" + .to_string(), + ); + } + let cleanup_target = { + let record = records + .iter_mut() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; stop_managed_agent_process(&app, record, &mut runtimes)?; + if let BackendKind::Provider { id, config } = &record.backend { + if id == "hermes" + && hermes_owner.as_deref() == Some(pubkey.as_str()) + && !hermes_scope_has_other + { + Some((resolve_provider_binary(id)?, config.clone())) + } else { + None + } + } else { + None + } + }; + if let Some((binary, config)) = cleanup_target { + { + let record = records + .iter_mut() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + record.last_error = Some("remote Hermes cleanup pending".to_string()); + record.updated_at = now_iso(); + } + save_managed_agents(&app, &records)?; + if let Err(error) = provider_cleanup(&binary, &config) { + { + let record = records + .iter_mut() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + record.last_error = Some(error.clone()); + record.updated_at = now_iso(); + } + save_managed_agents(&app, &records)?; + return Err(error); + } } state.clear_agent_session_caches(&pubkey); let initial_len = records.len(); diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..1463739672 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -149,13 +149,26 @@ fn reconcile_inbound_persona_event_blocking( match kind { KIND_PERSONA => { - let mut personas = load_personas(&app)?; - // `inbound_persona` is `Some` for KIND_PERSONA (set above). - apply_inbound_persona( - &mut personas, - inbound_persona.expect("persona parsed above"), - ); - save_personas(&app, &personas)?; + let inbound = inbound_persona.expect("persona parsed above"); + let provider_owned = load_managed_agents(&app)?.iter().any(|record| { + matches!( + &record.backend, + crate::managed_agents::BackendKind::Provider { id, .. } if id == "hermes" + ) && (record.pubkey == d_tag + || record.persona_id.as_deref() == Some(d_tag.as_str())) + }); + + // Native Hermes identities have a real local provider record and + // must never be projected into a keyless persona definition. The + // definition path would make the UI offer a second local ACP + // instance, which then fails when its Hermes provider is injected + // into buzz-acp. The provider record already carries the identity + // and its local persona linkage. + if !provider_owned { + let mut personas = load_personas(&app)?; + apply_inbound_persona(&mut personas, inbound); + save_personas(&app, &personas)?; + } } KIND_TEAM => { let mut teams = load_teams(&app)?; diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 84dd7e99da..e986b530ab 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::Duration; +use super::{BackendKind, ManagedAgentRecord}; + const STDERR_CAP: usize = 65536; /// Provider responses should be small JSON objects. Cap stdout to prevent a /// buggy or malicious provider from OOM-ing the desktop process. @@ -510,6 +512,33 @@ pub fn provider_deploy( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, +) -> Result { + provider_operation(binary, "deploy", Some(agent), provider_config) +} + +/// Stop a provider-managed agent through the provider's optional lifecycle +/// extension. Native Hermes gateways are supervised on their remote host and +/// cannot be stopped by the local-process path or ACP's `!shutdown` contract. +pub fn provider_stop( + binary: &Path, + provider_config: &serde_json::Value, +) -> Result { + provider_operation(binary, "stop", None, provider_config) +} + +/// Remove the provider-owned remote Buzz environment after stopping Hermes. +pub fn provider_cleanup( + binary: &Path, + provider_config: &serde_json::Value, +) -> Result { + provider_operation(binary, "cleanup", None, provider_config) +} + +fn provider_operation( + binary: &Path, + operation: &str, + agent: Option<&serde_json::Value>, + provider_config: &serde_json::Value, ) -> Result { let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; let info_request = serde_json::json!({ @@ -519,17 +548,151 @@ 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 mut request = serde_json::json!({ + "op": operation, "request_id": uuid::Uuid::new_v4().to_string(), - "agent": agent, "provider_config": provider_config, }); + if let Some(agent) = agent { + request["agent"] = agent.clone(); + } let resp = invoke_provider(&staged, &request, Duration::from_secs(600))?; resp["agent_id"] .as_str() .map(String::from) - .ok_or_else(|| "deploy response missing agent_id".to_string()) + .ok_or_else(|| format!("{operation} response missing agent_id")) +} + +fn canonical_remote_path(path: &str, user: &str) -> String { + let mut path = path.trim().replace('\\', "/"); + if !user.is_empty() { + for root in [format!("/Users/{user}"), format!("/home/{user}")] { + if path == root { + path = "~".to_string(); + break; + } + if let Some(rest) = path.strip_prefix(&(root.clone() + "/")) { + path = format!("~/{rest}"); + break; + } + } + } else { + for root in ["/Users/", "/home/"] { + if let Some(rest) = path.strip_prefix(root) { + path = match rest.split_once('/') { + Some((_, suffix)) => format!("~/{suffix}"), + None => "~".to_string(), + }; + break; + } + } + } + let tilde = path == "~" || path.starts_with("~/"); + let absolute = path.starts_with('/'); + let normalized_input = if tilde { + path.strip_prefix("~/") + .or_else(|| path.strip_prefix('~')) + .unwrap_or(&path) + } else { + path.as_str() + }; + let mut parts = Vec::new(); + for part in normalized_input.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + value => parts.push(value), + } + } + let joined = parts.join("/"); + if tilde { + if joined.is_empty() { + "~".to_string() + } else { + format!("~/{joined}") + } + } else if absolute { + format!("/{joined}") + } else { + joined + } +} + +/// Return the canonical lifecycle scope for a native Hermes provider. +/// +/// One supervised Hermes unit owns one identity. Optional provider settings +/// such as channels and model do not create a second lifecycle scope. The +/// effective profile path follows the remote script's default/empty-profile +/// rules, so explicit and implicit default paths collide as intended. +pub fn hermes_provider_lifecycle_scope( + backend: &BackendKind, +) -> Option<(String, String, String)> { + let BackendKind::Provider { id, config } = backend else { + return None; + }; + if id != "hermes" { + return None; + } + let component = |key: &str| { + config + .get(key) + .and_then(|value| value.as_str()) + .map(str::trim) + .unwrap_or("") + .to_string() + }; + let user = component("user"); + let profile = component("profile"); + let profile_name = if profile.is_empty() || profile == "default" { + "default" + } else { + profile.as_str() + }; + let home = { + let value = component("home"); + if value.is_empty() { + "~/.hermes".to_string() + } else { + value + } + }; + let configured_profile_home = component("profile_home"); + let effective_profile_home = if configured_profile_home.is_empty() { + if profile_name == "default" { + home.clone() + } else { + format!("{home}/profiles/{profile_name}") + } + } else { + configured_profile_home + }; + // The provider requires an explicit SSH user before any operation. Do not + // include it in the scope key: this conservatively treats equivalent + // `~` paths on one host as one lifecycle scope even for legacy records that + // predate the required-user validation. + Some(( + component("host").to_ascii_lowercase(), + component("unit"), + canonical_remote_path(&effective_profile_home, &user), + )) +} + +/// The first persisted record is the explicit owner of a duplicate scope. +/// This recovery rule lets Desktop delete later conflicting records without +/// stopping or cleaning the owner's gateway; normal creation still rejects a +/// new collision. +pub fn hermes_provider_scope_owner( + records: &[ManagedAgentRecord], + pubkey: &str, +) -> Option { + let target = records.iter().find(|record| record.pubkey == pubkey)?; + let scope = hermes_provider_lifecycle_scope(&target.backend)?; + records + .iter() + .find(|record| hermes_provider_lifecycle_scope(&record.backend).as_ref() == Some(&scope)) + .map(|record| record.pubkey.clone()) } /// Validate provider_config: flat object, scalar values, no secret-like keys. diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..dbb53652c8 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -8,9 +8,7 @@ use std::{ use tauri::{AppHandle, Manager}; use crate::app_state::keyring_service; -use crate::managed_agents::{ - ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt, -}; +use crate::managed_agents::{ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt}; use crate::secret_store::{KeyringProbe, SecretStore}; /// Keyring key name for an agent's nsec, namespaced from the human identity @@ -262,6 +260,9 @@ fn load_agent_store(app: &AppHandle) -> Result, String> pub fn load_managed_agents(app: &AppHandle) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); + // Duplicate Hermes scopes are retained for in-app recovery. The first + // persisted record is the explicit lifecycle owner; create rejects new + // collisions and delete can remove later conflicting records safely. hydrate_keys(&mut records); Ok(records) } diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..6371ccc93b 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -119,6 +119,16 @@ export async function stopManagedAgentWithRules({ agent: ManagedAgent; stopManagedAgent: StopManagedAgent; } & ManagedAgentChannelContext): Promise { + if ( + agent.backend.type === "provider" && + agent.backend.id === "hermes" + ) { + // Native Hermes is supervised on its host; use the provider's SSH stop + // operation instead of ACP's !shutdown message contract. + await stopManagedAgent(agent.pubkey); + return {}; + } + if (agent.backend.type === "provider") { const channelId = resolveManagedAgentChannelId(agent, { channels, @@ -154,7 +164,19 @@ export async function deleteManagedAgentWithRules({ deleteManagedAgent: DeleteManagedAgent; skipRemoteDeleteConfirm?: boolean; } & ManagedAgentActionContext): Promise { - if (agent.backend.type === "provider" && agent.backendAgentId) { + if ( + agent.backend.type === "provider" && + agent.backend.id === "hermes" + ) { + if (!skipRemoteDeleteConfirm) { + const confirmed = window.confirm( + "Delete this Hermes Desktop record? Remote cleanup occurs only when no other managed record shares its lifecycle scope.", + ); + if (!confirmed) { + return { cancelled: true }; + } + } + } else if (agent.backend.type === "provider" && agent.backendAgentId) { const presence = presenceLookup?.[normalizePubkey(agent.pubkey)]; const channelId = resolveManagedAgentChannelId(agent, { channels, diff --git a/desktop/src/features/agents/lib/managedAgentRuntimeLabel.test.mjs b/desktop/src/features/agents/lib/managedAgentRuntimeLabel.test.mjs new file mode 100644 index 0000000000..470e262054 --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentRuntimeLabel.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + managedAgentRuntimeLabel, + runtimeLabel, +} from "./managedAgentRuntimeLabel.ts"; + +const providerAgent = (id, agentCommand = "codex-acp") => ({ + backend: { type: "provider", id, config: {} }, + agentCommand, +}); + +test("provider backend wins over a stale Codex harness command", () => { + assert.equal(managedAgentRuntimeLabel(providerAgent("hermes")), "Hermes"); +}); + +test("unknown provider remains visibly remote", () => { + assert.equal( + managedAgentRuntimeLabel(providerAgent("kubernetes")), + "Remote (kubernetes)", + ); +}); + +test("local known and custom commands retain their labels", () => { + assert.equal(runtimeLabel("codex-acp"), "Codex"); + assert.equal(runtimeLabel("hermes"), "Hermes"); + assert.equal(runtimeLabel("/opt/my-agent"), "/opt/my-agent"); +}); diff --git a/desktop/src/features/agents/lib/managedAgentRuntimeLabel.ts b/desktop/src/features/agents/lib/managedAgentRuntimeLabel.ts new file mode 100644 index 0000000000..f8ca93a4e3 --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentRuntimeLabel.ts @@ -0,0 +1,29 @@ +import type { ManagedAgent } from "@/shared/api/types"; + +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + hermes: "Hermes", + aider: "Aider", +}; + +export function runtimeLabel(command: string | null | undefined): string { + const value = command?.trim() ?? ""; + return RUNTIME_LABELS[value.toLowerCase()] ?? (value || "Custom"); +} + +/** + * Provider ownership is authoritative for display. A stale or legacy harness + * command must not make a native provider agent appear to be Codex/ACP. + */ +export function managedAgentRuntimeLabel( + agent: Pick, +): string { + if (agent.backend.type === "provider") { + const providerId = agent.backend.id.trim().toLowerCase(); + if (providerId === "hermes") return "Hermes"; + return providerId ? `Remote (${providerId})` : "Remote"; + } + return runtimeLabel(agent.agentCommand); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 62a4169fc9..2758941afb 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -9,6 +9,7 @@ import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { managedAgentRuntimeLabel } from "@/features/agents/lib/managedAgentRuntimeLabel"; import { useNow } from "@/shared/lib/useNow"; import type { ManagedAgent, @@ -405,7 +406,7 @@ function RuntimeBlock({
Runtime

- {agent.agentCommand} + {managedAgentRuntimeLabel(agent)}

{runtimeSource || agent.model ? (
diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index cf88919ce6..1950f5c2b0 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -12,6 +12,10 @@ import { } from "lucide-react"; import * as React from "react"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { + managedAgentRuntimeLabel, + runtimeLabel, +} from "@/features/agents/lib/managedAgentRuntimeLabel"; import { truncatePubkey } from "@/shared/lib/pubkey"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { PubKey } from "@/shared/ui/PubKey"; @@ -23,17 +27,6 @@ import type { RelayAgent, } from "@/shared/api/types"; -const RUNTIME_LABELS: Record = { - goose: "Goose", - "claude-code": "Claude Code", - "codex-acp": "Codex", - aider: "Aider", -}; - -function runtimeLabel(command: string): string { - return RUNTIME_LABELS[command] ?? command; -} - export type ProfileField = { copyValue?: string; displayValue: string; @@ -112,7 +105,14 @@ export function useProfileFieldBuckets({ }) { return React.useMemo(() => { const metadataFields = [ - ...buildPublicFields({ pubkey, profile, relayAgent, isBot, persona }), + ...buildPublicFields({ + managedAgent, + pubkey, + profile, + relayAgent, + isBot, + persona, + }), ...(ownerDisplayName || isOwner === true ? buildOwnerFields({ includeOperationalFields: isOwner === true, @@ -152,12 +152,14 @@ export function useProfileFieldBuckets({ export function buildPublicFields({ isBot, + managedAgent, persona, profile, pubkey, relayAgent, }: { isBot: boolean; + managedAgent?: ManagedAgent; persona?: AgentPersona; profile: Profile | undefined; pubkey: string | null; @@ -184,10 +186,13 @@ export function buildPublicFields({ }); } - if (isBot && relayAgent?.agentType) { + if (isBot && (managedAgent || relayAgent?.agentType)) { + const agentType = managedAgent + ? managedAgentRuntimeLabel(managedAgent) + : runtimeLabel(relayAgent?.agentType); fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), + copyValue: managedAgent?.agentCommand || relayAgent?.agentType || undefined, + displayValue: agentType, icon: Cpu, label: "Agent type", testId: "user-profile-agent-type", @@ -294,10 +299,14 @@ export function buildOwnerFields({ return fields; } - if (managedAgent?.agentCommand) { + if (managedAgent) { fields.push({ - copyValue: managedAgent.agentCommand, - displayValue: runtimeLabel(managedAgent.agentCommand), + copyValue: + managedAgent.agentCommand || + (managedAgent.backend.type === "provider" + ? managedAgent.backend.id + : undefined), + displayValue: managedAgentRuntimeLabel(managedAgent), icon: Terminal, label: "Runtime", testId: "user-profile-runtime", diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 06295cc615..e280571fd9 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -29,6 +29,10 @@ import { ownsAuthorAgent, } from "@/features/profile/lib/identity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { + managedAgentRuntimeLabel, + runtimeLabel, +} from "@/features/agents/lib/managedAgentRuntimeLabel"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; @@ -71,17 +75,6 @@ type UserProfilePopoverProps = { const HOVER_OPEN_DELAY_MS = 500; const HOVER_CLOSE_DELAY_MS = 200; -const RUNTIME_LABELS: Record = { - goose: "Goose", - "claude-code": "Claude Code", - "codex-acp": "Codex", - aider: "Aider", -}; - -function runtimeLabel(command: string): string { - return RUNTIME_LABELS[command] ?? command; -} - function InfoBadge({ children }: { children: React.ReactNode }) { return ( @@ -624,8 +617,8 @@ export function UserProfilePopover({ {isBotProfile && (managedAgent || relayAgent) ? (
- {managedAgent?.agentCommand ? ( - {runtimeLabel(managedAgent.agentCommand)} + {managedAgent ? ( + {managedAgentRuntimeLabel(managedAgent)} ) : relayAgent?.agentType ? ( {runtimeLabel(relayAgent.agentType)} ) : null}