diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 2b56d3deab2e..ada3e6af9e2e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -131,9 +131,9 @@ mod agent_inventory; mod agent_session_attach; #[cfg(unix)] pub use agent_session_attach::{ - AGENT_SESSION_ATTACH_CAPABILITY, AGENT_SESSION_START_CAPABILITY, - AGENT_SESSION_START_KEYED_REASON_CAPABILITY, AGENT_SESSION_TRUST_CAPABILITY, AcpmuxStarter, - AgentStartHost, AgentStartHostSource, + AGENT_SESSION_ADOPT_CAPABILITY, AGENT_SESSION_ATTACH_CAPABILITY, + AGENT_SESSION_START_CAPABILITY, AGENT_SESSION_START_KEYED_REASON_CAPABILITY, + AGENT_SESSION_TRUST_CAPABILITY, AcpmuxStarter, AgentStartHost, AgentStartHostSource, }; #[cfg(unix)] pub mod agent_transfer; diff --git a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_adopt.rs b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_adopt.rs new file mode 100644 index 000000000000..cda789d70ad6 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_adopt.rs @@ -0,0 +1,147 @@ +//! Remote agent session adopt (`agent-session-adopt-v1`): a trusted local +//! client (an app on another Mac, through the SSH or Cloud carrier) opens a +//! chat that already runs in THIS machine's acpmux (an agent or `cmux agent +//! open` started it here) in an agent tab of this daemon's store, and the +//! daemon binds it to the tab. The app then shows it through +//! `agent-session-attach-v1`, as for a started chat. +//! +//! `agent-session-adopt {surface, session}` answers `{surface, session, +//! conversation, family, mode, asking}`. +//! +//! Scope: the tab is checked as for `agent-session-start` (an unbound +//! `agent_session` tab of this store), and the session must be one this +//! machine's own acpmux lists (`_acpmux/sessions`): never a chat mirrored +//! from a peer (a session never leaves its machine, cx-k21v), never the Home +//! Chief's, never an id acpmux does not know (`agent_session.unknown_session`). +//! acpmux is never started for an adopt: nothing listening runs no chat. +//! +//! Permissions: the session keeps its mode. A chat in a mode that does not ask +//! before acting (bypass) is adopted too, only on a host the start's host gate +//! allows (a Mac, an SSH host or an owner-bound Cloud VM the person enrolled; +//! never a team VM or a Cloud VM of unknown kind), and the answer says so +//! (`asking: false`), which the app shows as a "bypass" badge on the +//! workspace. The daemon changes no mode. + +use std::path::PathBuf; +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::agent_session_link::AcpmuxLink; +use super::agent_session_start::{ + AgentSessionStarts, CALL_TIMEOUT, THREAD_STACK_BYTES, checked_mode, is_chief, startable, +}; +use super::{MessageWriter, Refusal, SurfaceId, ok, refuse}; +use crate::mux::Mux; +use crate::state::conversation_tabs_store::{validate_harness, validate_session}; + +pub const AGENT_SESSION_ADOPT_CAPABILITY: &str = "agent-session-adopt-v1"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct AdoptParams { + surface: SurfaceId, + session: String, +} + +/// `agent-session-adopt` of a trusted local connection (the parent checked +/// trust and the acpmux socket). +pub(super) fn adopt( + mux: &Arc, + client: u64, + id: Option, + socket: PathBuf, + params: AdoptParams, + writer: &MessageWriter, +) -> bool { + if validate_session(¶ms.session).is_err() { + return refuse(writer, id, Refusal::BadRequest); + } + if let Err(refusal) = mux.control_clients.agent_sessions.starts.host_gate() { + return refuse(writer, id, refusal); + } + if let Err(refusal) = startable(mux, params.surface) { + return refuse(writer, id, refusal); + } + let Some(slot) = AgentSessionStarts::reserve(mux, params.surface) else { + return refuse(writer, id, Refusal::Limit); + }; + let worker_mux = mux.clone(); + let worker_writer = writer.clone(); + let worker_id = id.clone(); + let spawned = std::thread::Builder::new() + .name("mux-agent-adopt".into()) + .stack_size(THREAD_STACK_BYTES) + .spawn(move || { + let result = run(&worker_mux, client, &socket, ¶ms); + drop(slot); + match result { + Ok(data) => ok(&worker_writer, worker_id, data), + Err(refusal) => refuse(&worker_writer, worker_id, refusal), + }; + }); + // A failed spawn drops the closure and with it the slot. + if spawned.is_err() { + return refuse(writer, id, Refusal::Limit); + } + true +} + +fn run( + mux: &Arc, + client: u64, + socket: &std::path::Path, + params: &AdoptParams, +) -> Result { + let link = AcpmuxLink::connect(socket, |_| {}).map_err(|_| Refusal::AcpmuxUnavailable)?; + let result = adopt_and_bind(mux, client, &link, params); + link.close(); + result +} + +fn adopt_and_bind( + mux: &Arc, + client: u64, + link: &AcpmuxLink, + params: &AdoptParams, +) -> Result { + let listed = link.call("_acpmux/sessions", json!({}), CALL_TIMEOUT).map_err(Refusal::of)?; + let summary = own_session(&listed, ¶ms.session).ok_or(Refusal::UnknownSession)?; + let table = link + .call("_acpmux/web_modes", json!({"sessionId": params.session}), CALL_TIMEOUT) + .map_err(Refusal::of)?; + let facts = checked_mode(¶ms.session, &table)?; + let family = facts["family"].as_str().unwrap_or_default(); + // The tab's brand mark: the agent acpmux runs (a profile by its last + // part), else its family. + let harness = [summary.get("harness").and_then(Value::as_str), Some(family)] + .into_iter() + .flatten() + .filter_map(|harness| harness.rsplit('/').next()) + .find(|harness| validate_harness(harness).is_ok()); + // The tab may have closed, or a start or adopt bound it, meanwhile. + let actor = super::super::origin_gate::connection_actor(mux, client); + let (record, _) = mux + .bind_conversation_tab_session_as(&actor, params.surface, ¶ms.session, None, harness) + .map_err(|_| startable(mux, params.surface).err().unwrap_or(Refusal::Refused))?; + Ok(json!({ + "surface": params.surface, + "session": params.session, + "conversation": record.wire(), + "family": family, + "mode": facts["mode"], + "asking": facts["asking"], + })) +} + +/// `session`'s summary in a `_acpmux/sessions` answer when it is a chat of +/// this machine: not mirrored from a peer and not the Home Chief's. +fn own_session<'a>(listed: &'a Value, session: &str) -> Option<&'a Value> { + let list = listed.get("sessions").unwrap_or(listed).as_array()?; + list.iter().find(|summary| { + summary.get("sessionId").and_then(Value::as_str) == Some(session) + && summary.get("peer").is_none_or(Value::is_null) + && !is_chief(summary) + }) +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs index 4b3138755bc1..adfe323b82ee 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs @@ -15,6 +15,8 @@ use std::time::Duration; use serde::Deserialize; use serde_json::{Value, json}; +#[path = "agent_session_adopt.rs"] +mod agent_session_adopt; #[path = "agent_session_children.rs"] pub(super) mod agent_session_children; #[path = "agent_session_link.rs"] @@ -30,6 +32,7 @@ mod inventory_socket; use super::{MessageWriter, OutboundStream, Response, SurfaceId, send_response}; use crate::mux::Mux; use crate::state::conversation_tabs_store::ConversationTabRecord; +pub use agent_session_adopt::AGENT_SESSION_ADOPT_CAPABILITY; use agent_session_link::{AcpmuxLink, Inbound, LinkError}; pub use agent_session_start::{ AGENT_SESSION_START_CAPABILITY, AGENT_SESSION_START_KEYED_REASON_CAPABILITY, AcpmuxStarter, @@ -335,6 +338,8 @@ pub(crate) enum Refusal { UnknownPermission, /// `agent-session-start`: the tab already has a session. Bound, + /// `agent-session-adopt`: this machine's acpmux lists no such chat of its own. + UnknownSession, /// `agent-session-start`: acpmux is not running and could not be started. AcpmuxUnavailable, /// `agent-session-start`: the agent has no mode that asks before acting. @@ -369,6 +374,7 @@ impl Refusal { Self::Refused => "agent_session.refused", Self::UnknownPermission => "agent_session.unknown_permission", Self::Bound => "agent_session.bound", + Self::UnknownSession => "agent_session.unknown_session", Self::AcpmuxUnavailable => "agent_session.acpmux_unavailable", Self::NotAsking => "agent_session.not_asking", Self::UntrustedFolder => "agent_session.untrusted_folder", @@ -394,6 +400,7 @@ impl Refusal { Self::Refused => "acpmux refused the request", Self::UnknownPermission => "no such pending permission request on this attachment", Self::Bound => "the agent tab already has a session", + Self::UnknownSession => "this machine runs no such chat of its own", Self::AcpmuxUnavailable => { "acpmux is not running on this machine and could not be started" } @@ -456,6 +463,9 @@ pub(super) fn try_handle( if cmd == "agent-session-trust" { return Some(trust(mux, client, id, object, writer)); } + if cmd == "agent-session-adopt" { + return Some(adopt(mux, client, id, object, writer)); + } let Some(command) = AgentSessionCommand::parse(&cmd, Value::Object(object)) else { return Some(refuse(writer, id, Refusal::BadRequest)); }; @@ -508,6 +518,26 @@ fn start( agent_session_start::start(mux, client, id, socket, params, writer) } +/// `agent-session-adopt` (agent_session_adopt.rs), with the same checks. +fn adopt( + mux: &Arc, + client: u64, + id: Option, + params: serde_json::Map, + writer: &MessageWriter, +) -> bool { + let Ok(params) = serde_json::from_value(Value::Object(params)) else { + return refuse(writer, id, Refusal::BadRequest); + }; + if !mux.control_clients.is_unix(client) || mux.is_remote_client(client) { + return refuse(writer, id, Refusal::NotTrusted); + } + let Some(socket) = mux.control_clients.agent_sessions.socket() else { + return refuse(writer, id, Refusal::Unavailable); + }; + agent_session_adopt::adopt(mux, client, id, socket, params, writer) +} + /// `agent-session-trust` (agent_session_trust.rs), with the same checks. fn trust( mux: &Arc, diff --git a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_start.rs b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_start.rs index d1563309e919..5aedcdd5e5ce 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_start.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_start.rs @@ -81,8 +81,8 @@ const MAX_STARTS: usize = 8; const MAX_CWD_BYTES: usize = 4096; /// A harness spawns and answers `session/new` (a cold agent can take long). const NEW_TIMEOUT: Duration = Duration::from_secs(60); -const CALL_TIMEOUT: Duration = Duration::from_secs(5); -const THREAD_STACK_BYTES: usize = 256 * 1024; +pub(super) const CALL_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const THREAD_STACK_BYTES: usize = 256 * 1024; /// Reads this machine's [`AgentStartHost`] (a few file checks), at each start. pub type AgentStartHostSource = Arc AgentStartHost + Send + Sync>; @@ -109,7 +109,7 @@ impl AgentSessionStarts { /// Ok on an allowed host; the refusal otherwise (also when unset). The /// kind is read again for every start, so a bind or team enroll that /// lands after the daemon started counts at once. - fn host_gate(&self) -> Result<(), Refusal> { + pub(super) fn host_gate(&self) -> Result<(), Refusal> { let source = self.host.lock().unwrap_or_else(|e| e.into_inner()).clone(); match source.map(|read| read()) { Some(AgentStartHost::Allowed) => Ok(()), @@ -124,7 +124,7 @@ impl AgentSessionStarts { /// Takes `surface`'s start slot; None while it starts or at the cap. /// The slot is given back when the returned guard drops (also on a panic). - fn reserve(mux: &Arc, surface: SurfaceId) -> Option { + pub(super) fn reserve(mux: &Arc, surface: SurfaceId) -> Option { let starts = &mux.control_clients.agent_sessions.starts; let mut starting = starts.starting.lock().unwrap_or_else(|e| e.into_inner()); (starting.len() < MAX_STARTS && starting.insert(surface)) @@ -133,7 +133,7 @@ impl AgentSessionStarts { } /// A taken start slot. -struct StartSlot { +pub(super) struct StartSlot { mux: Arc, surface: SurfaceId, } @@ -313,7 +313,7 @@ pub(crate) fn keyed_restart_reason(status: Option<&Value>) -> Value { } /// Ok when `surface` is an unbound agent tab whose record names this store. -fn startable(mux: &Mux, surface: SurfaceId) -> Result<(), Refusal> { +pub(super) fn startable(mux: &Mux, surface: SurfaceId) -> Result<(), Refusal> { let runtime = mux.surface(surface).ok_or(Refusal::UnknownTab)?; match mux.conversation_tab_of(&runtime) { Some(ConversationTabRecord::AgentSession { host, session, .. }) @@ -499,6 +499,59 @@ fn refused_family(table: &Value, family: &str) -> bool { .is_some_and(|refused| refused.iter().any(|name| name.as_str() == Some(family))) } +/// Whether a `_acpmux/web_modes {sessionId}` answer's session asks before +/// acting (its family is not refused and its mode is one of the family's +/// asking modes; no mode counts as asking), and the family's asking default. +pub(super) fn asks(table: &Value) -> (bool, Option) { + let family = table.pointer("/session/family").and_then(Value::as_str).unwrap_or_default(); + let modes: Vec<&str> = table + .pointer(&format!("/families/{}", family.replace('~', "~0").replace('/', "~1"))) + .and_then(Value::as_array) + .map(|modes| modes.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let default = modes.first().map(|mode| mode.to_string()); + if family.is_empty() || refused_family(table, family) { + return (false, None); + } + let asks = match table.pointer("/session/mode").and_then(Value::as_str) { + None => true, + Some(mode) => modes.contains(&mode), + }; + (asks, default) +} + +/// `session`'s mode in a `_acpmux/web_modes {sessionId}` answer, as the adopt +/// reply carries it: its family, its mode and whether it asks before acting +/// ([`asks`]); the app shows `asking: false` as the bypass badge. +fn mode_facts(session: &str, table: &Value) -> Value { + let (asking, _) = asks(table); + json!({ + "session": session, + "family": table.pointer("/session/family").and_then(Value::as_str).unwrap_or_default(), + "mode": table.pointer("/session/mode").and_then(Value::as_str), + "asking": asking, + }) +} + +/// [`mode_facts`] of `session` in `table`, only when acpmux knows that +/// session by exactly this id (an unknown or purged one has no `session`, and +/// a prefix resolves to another id): never facts for a chat that is not there. +pub(super) fn checked_mode(session: &str, table: &Value) -> Result { + if table.pointer("/session/sessionId").and_then(Value::as_str) != Some(session) { + return Err(Refusal::UnknownSession); + } + Ok(mode_facts(session, table)) +} + +/// A Home Chief session (acpmux `hub/route_switch.rs` is_chief, the app's +/// `AcpmuxSessionCensus.isChief`), from a `_acpmux/sessions` summary. +pub(in crate::server) fn is_chief(summary: &Value) -> bool { + let text = |key: &str| summary[key].as_str().unwrap_or_default(); + summary.field("tags").get("cmux.chief").is_some() + || text("name").starts_with("optchat-") + || text("harness").starts_with("optchat-compact-") +} + /// acpmux's remote rule for a new session: its family is not refused and /// its mode asks (no mode counts as asking), else it moves to the family's /// asking default; anything else is refused. Returns the session's family. @@ -507,23 +560,6 @@ fn settle_asking_mode(link: &AcpmuxLink, session: &str) -> Result (bool, Option) { - let family = table.pointer("/session/family").and_then(Value::as_str).unwrap_or_default(); - let modes: Vec<&str> = table - .pointer(&format!("/families/{}", family.replace('~', "~0").replace('/', "~1"))) - .and_then(Value::as_array) - .map(|modes| modes.iter().filter_map(Value::as_str).collect()) - .unwrap_or_default(); - let default = modes.first().map(|mode| mode.to_string()); - if family.is_empty() || refused_family(table, family) { - return (false, None); - } - let asks = match table.pointer("/session/mode").and_then(Value::as_str) { - None => true, - Some(mode) => modes.contains(&mode), - }; - (asks, default) - }; let table = read()?; let family = table.pointer("/session/family").and_then(Value::as_str).unwrap_or_default().to_string(); diff --git a/cmux-tui/crates/cmux-tui-core/src/server/agent_transfer/agent_update.rs b/cmux-tui/crates/cmux-tui-core/src/server/agent_transfer/agent_update.rs index f01c3548b2d4..285bedb609d8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/agent_transfer/agent_update.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/agent_transfer/agent_update.rs @@ -348,7 +348,9 @@ fn threads(sessions: &Value) -> (usize, usize) { let list = sessions.get("sessions").unwrap_or(sessions); let (mut live, mut running) = (0, 0); for summary in list.as_array().into_iter().flatten() { - if summary.get("peer").is_some_and(|peer| !peer.is_null()) || is_chief(summary) { + if summary.get("peer").is_some_and(|peer| !peer.is_null()) + || agent_session_start::is_chief(summary) + { continue; } match summary.field("status").as_str() { @@ -363,15 +365,6 @@ fn threads(sessions: &Value) -> (usize, usize) { (live, running) } -/// A Home Chief session (acpmux `hub/route_switch.rs` is_chief, the app's -/// `AcpmuxSessionCensus.isChief`). -fn is_chief(summary: &Value) -> bool { - let text = |key: &str| summary[key].as_str().unwrap_or_default(); - summary.field("tags").get("cmux.chief").is_some() - || text("name").starts_with("optchat-") - || text("harness").starts_with("optchat-compact-") -} - // MARK: Apply fn apply(mux: &Arc, socket: &Path, params: ApplyParams) -> Result { diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversation_tabs_wire.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversation_tabs_wire.rs index b18e9adec84d..2f5df6ded07a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversation_tabs_wire.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversation_tabs_wire.rs @@ -152,7 +152,7 @@ pub(super) struct AgentSessionParams { const MACHINE_HOST_PREFIX: &str = "registry:"; /// A session for a `registry:` tab from anywhere but that machine's own -/// `agent-session-start` (cx-k21v): a session never leaves its machine, so +/// `agent-session-start` or `agent-session-adopt` (cx-k21v): a session never leaves its machine, so /// another machine's chat (a Mac's acpmux session or an outside chat it /// resumes) is never bound into a tab of this one. Two exceptions, both chats /// of this machine's own acpmux ([`landed_here`]): a chat this daemon itself @@ -231,8 +231,8 @@ fn record_of( } (None, None, Some(AgentSessionParams { host, session, harness, host_name }), None) => { // A machine's own tab is created without a session; only that - // machine's agent-session-start binds one, or it names a chat - // this daemon landed. + // machine's agent-session-start or agent-session-adopt binds one, + // or it names a chat this daemon landed. if host.starts_with(MACHINE_HOST_PREFIX) && let Some(session) = &session && !landed_here(mux, &host, session) @@ -341,8 +341,9 @@ pub(super) fn bind( params: BindSessionParams, ) -> anyhow::Result { let BindSessionParams { surface, session, expected_session, harness } = params; - // A machine's own tab binds only through agent-session-start (which - // binds in the store directly, never through this command). + // A machine's own tab binds only through agent-session-start or + // agent-session-adopt (which bind in the store directly, never through + // this command). let machine_tab = mux.surface(surface).and_then(|runtime| mux.conversation_tab_of(&runtime)); if let Some(ConversationTabRecord::AgentSession { host, .. }) = machine_tab && host.starts_with(MACHINE_HOST_PREFIX) diff --git a/cmux-tui/crates/cmux-tui/tests/cli/agent_session_adopt.rs b/cmux-tui/crates/cmux-tui/tests/cli/agent_session_adopt.rs new file mode 100644 index 000000000000..54680440e0ae --- /dev/null +++ b/cmux-tui/crates/cmux-tui/tests/cli/agent_session_adopt.rs @@ -0,0 +1,94 @@ +//! `agent-session-adopt` (agent-session-adopt-v1): the app opens a chat that +//! already runs in this machine's acpmux (one an agent or `cmux agent open` +//! started there) in an agent tab of this machine, as a workspace of its +//! sidebar. The daemon binds the session only when this machine's own acpmux +//! lists it: never a chat mirrored from a peer, never the Home Chief's, never +//! an id acpmux does not know. A session in a mode that does not ask is +//! adopted too, on an enrolled host only (the start's host gate), and the +//! answer says so (`asking: false`) for the app's bypass badge. + +use std::os::unix::net::UnixListener; + +use serde_json::{Value, json}; + +use super::*; + +/// An acpmux stand-in that lists one chat of this machine in a bypass mode, +/// one mirrored from a peer and one of the Home Chief. +fn listing_acpmux(socket: PathBuf) { + let listener = UnixListener::bind(&socket).unwrap(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + std::thread::spawn(move || { + let mut writer = stream.try_clone().unwrap(); + for line in BufReader::new(stream).lines() { + let Ok(line) = line else { return }; + let Ok(message) = serde_json::from_str::(&line) else { continue }; + let Some(id) = message.get("id").cloned() else { continue }; + let result = match message["method"].as_str().unwrap_or_default() { + "_acpmux/sessions" => json!({"sessions": [ + {"sessionId": "own-1", "status": "ready", "peer": null, + "harness": "fake", "name": "lane", "tags": {}}, + {"sessionId": "peer-1", "status": "ready", "peer": "mac-a", + "harness": "fake", "tags": {}}, + {"sessionId": "chief-1", "status": "ready", "peer": null, + "harness": "fake", "tags": {"cmux.chief": "home"}}, + ]}), + "_acpmux/web_modes" => json!({ + "refusedFamilies": [], "families": {"fake": ["ask"]}, + "session": {"sessionId": "own-1", "family": "fake", + "mode": "bypassPermissions"}, + }), + _ => json!({}), + }; + let reply = json!({"jsonrpc": "2.0", "id": id, "result": result}); + if writer.write_all(format!("{reply}\n").as_bytes()).is_err() { + return; + } + } + }); + } + }); +} + +impl Daemon { + fn adopt(&self, surface: u64, session: &str) -> Value { + self.rpc(json!({"id": 21, "cmd": "agent-session-adopt", "surface": surface, + "session": session})) + } +} + +#[test] +fn agent_session_adopt_binds_a_chat_of_this_machine_and_names_its_bypass_mode() { + let daemon = Daemon::start_with("adopt", None, None); + listing_acpmux(daemon.dir.join("acpmux/acpmux.sock")); + // Not advertised until the mode reads land with it (the app sends + // `mode_events` and `agent-session-mode` to a daemon that advertises it). + let identity = daemon.rpc(json!({"id": 1, "cmd": "identify"})); + assert!(!identity["data"]["capabilities"].to_string().contains("agent-session-adopt-v1")); + let surface = daemon.own_agent_tab(); + for session in ["peer-1", "chief-1", "nope-1"] { + let refused = daemon.adopt(surface, session); + assert_eq!(refused["error_code"], "agent_session.unknown_session", "{session}: {refused}"); + } + let adopted = daemon.adopt(surface, "own-1"); + assert_eq!(adopted["ok"], true, "{adopted}"); + assert_eq!(adopted["data"]["session"], "own-1", "{adopted}"); + assert_eq!(adopted["data"]["asking"], false, "{adopted}"); + assert_eq!(adopted["data"]["mode"], "bypassPermissions", "{adopted}"); + assert_eq!(adopted["data"]["conversation"]["agent_session"]["session"], "own-1", "{adopted}"); + let again = daemon.adopt(surface, "own-1"); + assert_eq!(again["error_code"], "agent_session.bound", "{again}"); + let _ = fs::remove_file(daemon.dir.join("acpmux/acpmux.sock")); +} + +#[test] +fn agent_session_adopt_is_refused_on_a_team_vm() { + let daemon = Daemon::start_with("adopt-team", Some("team-vm"), None); + listing_acpmux(daemon.dir.join("acpmux/acpmux.sock")); + let surface = daemon.own_agent_tab(); + let refused = daemon.adopt(surface, "own-1"); + assert_eq!(refused["error_code"], "agent_session.team_vm_blocked", "{refused}"); + let _ = fs::remove_file(daemon.dir.join("acpmux/acpmux.sock")); +} diff --git a/cmux-tui/crates/cmux-tui/tests/cli/agent_session_start.rs b/cmux-tui/crates/cmux-tui/tests/cli/agent_session_start.rs index 0051410874c1..ee2ec5b5e2d5 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/agent_session_start.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/agent_session_start.rs @@ -23,6 +23,8 @@ mod agent_focus_cli; mod agent_home_reopen; #[path = "agent_input_hint.rs"] mod agent_input_hint; +#[path = "agent_session_adopt.rs"] +mod agent_session_adopt; #[path = "agents_chat_tools.rs"] mod agents_chat_tools; #[path = "agents_cli_receipt.rs"] diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 3ebca6b1b52b..53be6da58b30 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -9308,6 +9308,34 @@ key itself (and only on `safe_to_restart`, or after the person's click), then starts again; this check does not close the race with an agent that starts between the check and a restart (cx-ofowe). +### agent-session-adopt + +`agent-session-adopt-v1`: `{id, cmd:"agent-session-adopt", surface, session}` +binds a chat that already runs in this machine's own acpmux (one an agent or +`cmux agent open` started here) to an agent tab of this store, then answers +`{surface, session, conversation, family, mode, asking}`. The client then +shows it with `agent-session-attach`. The tab is checked as for +`agent-session-start` (`agent_session.unknown_tab`, `agent_session.bound`), +and so is the machine: a team VM answers `agent_session.team_vm_blocked`, a +Cloud VM of unknown kind `agent_session.host_unverified`. `session` (1 to 128 +letters, digits or `_`, `.`, `:`, `-`) must be listed by this machine's +`_acpmux/sessions` as its own: a chat mirrored from a peer (`peer` set), the +Home Chief's (`tags.cmux.chief`, an `optchat-` name or harness) and an id +acpmux does not list are `agent_session.unknown_session`. The daemon never +starts acpmux for an adopt (`agent_session.acpmux_unavailable` when nothing +listens). The session keeps its mode: `family` and `mode` are its +`_acpmux/web_modes` answer, and `asking` is false when that mode does not ask +before acting (or the family is one the remote table refuses). Such a chat is +adopted too, on the hosts the machine check allows, and the app shows it with +a "bypass" badge; the daemon changes no mode. The bind records the harness +acpmux lists for the session (a profile by its last part), else its family. +Starts and adopts share the per-daemon and per-tab limit +(`agent_session.limit`). Trust is the attach verbs': trusted local (Unix) +connections only. The daemon does not advertise `agent-session-adopt-v1` in +`identify` yet: it does once the mode reads the app relies on with it +(`agent-session-mode`, the attach reply's `agentMode`, `mode_events`) are +served too. + ### agent-session-trust `agent-session-trust-v1`: `{id, cmd:"agent-session-trust", surface, cwd, level?}`