diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index eb74ca1b8..8b1190357 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -1197,6 +1197,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.7" @@ -5800,6 +5809,7 @@ dependencies = [ "chrono", "ciborium", "cua-driver-sdk", + "dirs 6.0.0", "futures-util", "goose", "goose-providers", @@ -5811,12 +5821,14 @@ dependencies = [ "log", "maple-proxy", "maple-sdk", + "notify", "once_cell", "process-wrap", "pulldown-cmark", "rand 0.8.7", "reqwest 0.13.4", "rmcp", + "rusqlite", "serde", "serde_json", "sha2 0.10.9", @@ -5831,6 +5843,7 @@ dependencies = [ name = "maple-agent-app" version = "0.1.0" dependencies = [ + "async-trait", "axum", "base64 0.22.1", "chrono", @@ -5841,24 +5854,25 @@ dependencies = [ "gpui", "gpui_platform", "image 0.25.10", + "libc", "log", "maple-agent", "maple-billing", "maple-proxy", + "maple-remote", "maple-sdk", - "notify", "parking_lot", "percent-encoding", "pulldown-cmark", "reqwest 0.13.4", "rodio", "rpassword", - "rusqlite", "semver", "serde", "serde_json", "spellbook", "tokio", + "tokio-util", "tower-http 0.6.11", "unicode-segmentation", "uuid", @@ -5897,6 +5911,27 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "maple-remote" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "futures-util", + "log", + "maple-agent", + "rand 0.8.7", + "serde", + "serde_json", + "sha2 0.10.9", + "snow", + "tokio", + "tokio-tungstenite 0.29.0", + "tokio-util", + "uuid", +] + [[package]] name = "maple-sdk" version = "4.0.1" @@ -9419,6 +9454,23 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305 0.10.1", + "curve25519-dalek", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "socket2" version = "0.6.5" diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index 465b15ac5..39ea2704e 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/maple-agent", "crates/maple-billing", "app"] +members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-remote", "app"] [workspace.package] edition = "2024" diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index b64e0aea7..bf7396dce 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -18,6 +18,9 @@ crates/maple-agent/ Maple's transport-neutral agent runtime, extracted from the Maple provider over the Maple Rust SDK, developer tools, permission policy, account-scoped session storage, and the ACP server. +crates/maple-remote/ The wire between a client and a host: framing, + JSON-RPC on the control channel, binary streams, + the host server, and the remote HostBackend. crates/maple-billing/ HTTP client for the Maple billing API. docs/ Theme spec measured from the Tauri app. scripts/ One maintainer helper: screenshot.py takes a desktop @@ -27,13 +30,21 @@ scripts/ One maintainer helper: screenshot.py takes a desktop ### Backend / frontend boundary -`app/src/backend.rs` owns the runtime: it is the only file that drives -`maple_agent`'s services, holding a private Tokio runtime and exposing an -async facade (`AgentBackend`) plus one event stream. UI modules import data -types from `maple_agent` (timeline items, session summaries) but talk to the -running agent through that facade only. This mirrors Maple's own edge-adapter -pattern, so a future process split replaces the facade without touching UI -code. +The UI talks to two facades and never to the runtime directly. + +`app/src/backend.rs` (`AgentBackend`) holds the account: the private Tokio +runtime, sign-in and OAuth, billing, audio, and the in-process agent +service. Everything a client drives on a host (tasks, projects, runs, +permissions, integrations, session defaults) goes through the +`maple_agent::host::HostBackend` trait. `AgentBackend::local_host` hands out +the in-process implementation, `LocalHostBackend`, which wraps +`AgentRuntimeHandle` and owns the host-side pieces the UI must not reach +around it for: the git branch watch, directory suggestions, and the SQLite +readers for context usage, tool summaries, and the usage page. Hosts push +`HostEvent`s (runtime events plus branch reports) through one fan-out hub. +A remote host implements the same trait over the wire, so the UI never +branches on where a host runs. See +[`docs/remote-development.md`](docs/remote-development.md) for the plan. The runtime was originally copied from Research’s Tauri source (now `apps/maple-research/frontend/src-tauri/src`) (`agent.rs`, @@ -121,6 +132,28 @@ Cargo manifests and lockfile; Research has an independent dependency graph. Nothing is downloaded or installed by the app. - Window size and maximized state persist between launches. +### Hosts + +Tasks can run on another machine. Settings > Hosts pairs this device with a +host running `maple-agent serve` (address plus the one-time code the host +printed) and lists the paired hosts with their connection state. Each row +also shows the version and build the host announced ("last seen" while it +is offline) and says when the host is behind this app, a different build +of the same version, newer than this app, or older than a release the +update check found. Saved hosts connect at launch and reconnect with +backoff. Their tasks join the +sidebar, badged with the host name once more than one host is known, and +the project switcher filters by host. With a task open the header names +the host it runs on; on the new-task screen a chip there names the host +new tasks run on and switches it. Both appear once more than one host is +known. The task itself is created there when its first message is sent. +The host the last new task ran on is the target again at the next launch +once it connects. Choosing a project opens one picker for every +host: a search box over the host's recent projects and folders and a row +that opens a typed path. Host-scoped settings (defaults, system prompt, +integrations, usage) get a host selector when more than one host is +connected. Offline hosts stay listed without their tasks until they return. + ### Integrations preview On macOS and Linux, Settings > Integrations can set up computer use inside @@ -366,6 +399,7 @@ maple-agent Open the desktop app. maple-agent acp Serve the Agent Client Protocol on stdio. maple-agent proxy [FLAGS] Serve an OpenAI-compatible HTTP endpoint. maple-agent login Sign in with email and password from a terminal. +maple-agent serve [FLAGS] Publish this machine's runtime to paired clients. maple-agent --version Print the version. ``` @@ -400,6 +434,68 @@ Without `--cors`, the proxy rejects requests that carry browser-only headers (`Origin`, `Sec-Fetch-Site`) so a web page cannot spend a saved key through loopback. With `--cors`, a default key is refused for the same reason. +### `maple-agent serve` + +``` +maple-agent serve Listen for paired clients. +maple-agent serve pair Publish a one-time pairing code. +maple-agent serve devices list Paired devices. +maple-agent serve devices revoke DEV Forget a device by key or name. + +--listen ADDR:PORT bind address (default 0.0.0.0:7130, env MAPLE_SERVE_LISTEN) +--name NAME host name clients show (default: hostname, env MAPLE_SERVE_NAME) +``` + +Runs this machine as a host for the desktop app on another machine, over +a LAN or a Tailscale network. It reuses the sign-in saved by `login` or +the desktop app and hosts its own runtime. Without a saved sign-in it +exits with a message; when the Maple server cannot be reached at start +(a unit that comes up before the network) it serves anyway, requests fail +until the sign-in goes through, and the sign-in is retried in the +background with growing pauses. The desktop app can serve the +same way: Settings > Hosts > "Allow remote connections" (off by default) +listens on the same port, publishes pairing codes, and lists paired +devices; the command and the window share the host key, the device list, +and the lock, so only one of them serves at a time. A device is admitted by a +one-time code: run `serve pair` on the host, enter the code in the app +within five minutes, and both sides pin each other's key; later connections +need no code. Devices and codes belong to the account that is hosting: +`serve pair` and `serve devices` act on the saved sign-in and refuse to run +without one, and a device paired while one account was signed in is not +admitted after another account signs in. Traffic is Noise-encrypted inside a plain WebSocket, so +pairing is the only gate and the listener binds every interface by default. +Repeated wrong codes lock the source address out. Revoking a device ends +its live connections within seconds. One `serve` per data root; a lock +file refuses a second. Each connect is logged with the client's name, +key, version, and build. See [`docs/remote-development.md`](docs/remote-development.md). + +`serve` is written to run under systemd: it stops cleanly on SIGTERM as +well as Ctrl-C, reports `READY=1` once the port is bound and `STOPPING=1` +on the way out when `NOTIFY_SOCKET` is set, and logs to stderr for the +journal. A user unit: + +```ini +[Unit] +Description=Maple host +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +NotifyAccess=main +ExecStart=%h/.local/bin/maple-agent serve --listen 100.64.0.7:7130 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 + +[Install] +WantedBy=default.target +``` + +Run `maple-agent login` once as that user first, then +`systemctl --user enable --now maple-serve`; `loginctl enable-linger` keeps +it up after logout. + ## Build features The default build has every mode. Cargo features turn modes off, so a @@ -411,6 +507,7 @@ window and its display libraries: | `desktop` | The gpui window. Without it the binary is headless. | | `acp` | `maple-agent acp` and `maple_agent::acp`. | | `proxy` | `maple-agent proxy`. | +| `serve` | `maple-agent serve` and the `maple-remote` host side. | ```sh cargo build --release -p maple-agent-app --no-default-features --features acp @@ -450,8 +547,8 @@ The roots follow the platform, the same way the Tauri app's | Path | Content | | --- | --- | -| `/settings.json` | App settings. | -| `/agent/accounts//config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust). May roam between machines. | +| `/settings.json` | Client-side app settings, plus per-host task and project state under `hosts`. | +| `/agent/accounts//config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust, session defaults: permission mode, web access, harness instructions). May roam between machines. | | `/agent/accounts//goose/config/` | Goose permission file for the account. | | `/agent/accounts//goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while any external agent is enabled. | | `/agent/goose-runtime/` | Goose process configuration. | @@ -461,6 +558,12 @@ The roots follow the platform, the same way the Tauri app's | `/agent/accounts//tool_summaries.db` | Model-written one-line summaries of tool calls (SQLite, WAL). | | `/agent/accounts//attachments/` | Image attachments. | | `/agent/acp/accounts//config.json` | ACP configuration. | +| `/remote/host_key.json` | This machine's static Noise key as a host (mode 0600). | +| `/remote/device_key.json` | This machine's static Noise key as a client device (mode 0600). | +| `/agent/accounts//hosts.json` | Hosts this account paired with: key, name, addresses, last seen version and build. | +| `/remote/accounts//devices.json` | Devices paired into this account on this host. Another account's host never admits them. | +| `/remote/accounts//pending_pairing.json` | The pairing code `serve pair` published for this account, until used or expired (mode 0600). | +| `/remote/serve.lock`, `serve.json` | The running host's lock and its listen address. | | `/logs/maple-agent.log` | Log file. Panics are logged here too. | Releases before the package rename used `maple-gpui` for both roots. On its diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 6100cc823..6988f9ca5 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -11,7 +11,7 @@ name = "maple-agent" path = "src/main.rs" [features] -default = ["desktop", "acp", "proxy"] +default = ["desktop", "acp", "proxy", "serve"] # The gpui window. Without it the binary is headless and needs no display # libraries; only `acp` and `proxy` modes remain. desktop = [ @@ -22,19 +22,23 @@ desktop = [ "dep:pulldown-cmark", "dep:cpal", "dep:rodio", - "dep:notify", "dep:spellbook", "dep:wayland-client", + "serve", ] # `maple-agent acp`: the Agent Client Protocol server on stdio. acp = ["maple-agent/acp"] # `maple-agent proxy`: the OpenAI-compatible HTTP endpoint. proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http"] +# `maple-agent serve`: publish this machine's agent runtime to paired clients. +serve = ["dep:tokio-util"] [dependencies] dirs = "6" maple-agent = { path = "../crates/maple-agent", default-features = false } maple-billing = { path = "../crates/maple-billing" } +maple-remote = { path = "../crates/maple-remote" } +tokio-util = { workspace = true, optional = true } # gpui is pinned to the Zed commit that gpui-libghostty (the embedded # terminal Ben is preparing) builds against, so the two agree on one gpui # once the terminal lands. crates.io 0.2.2 is older than this commit. @@ -45,7 +49,8 @@ gpui = { version = "0.2.2", git = "https://github.com/zed-industries/zed", rev = gpui-platform = { package = "gpui_platform", version = "0.1.0", git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1", features = ["font-kit", "wayland", "x11"], optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"], optional = true } base64 = "0.22" -tokio = { workspace = true } +tokio = { workspace = true, features = ["signal"] } +async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } semver = "1" @@ -58,9 +63,7 @@ unicode-segmentation = { version = "1", optional = true } pulldown-cmark = { version = "0.13", default-features = false, optional = true } cpal = { version = "0.17", optional = true } rodio = { version = "0.22", default-features = false, features = ["playback", "wav"], optional = true } -notify = { version = "8", optional = true } spellbook = { version = "0.4", optional = true } -rusqlite = { version = "0.32", features = ["bundled"] } chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } # Hidden password prompt for `maple-agent login`. @@ -71,6 +74,10 @@ percent-encoding = "2" axum = { version = "0.8", optional = true } tower-http = { version = "0.6", features = ["cors"], optional = true } +# `gethostname`, for the name hosts and devices show each other. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + # Used only to ask the compositor whether it draws window decorations; see # `ui::decorations`. gpui cannot answer that question. [target.'cfg(target_os = "linux")'.dependencies] diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 9f9f18e22..97248b685 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -1,9 +1,11 @@ -//! Backend boundary for the gpui frontend. +//! Account-level backend for the gpui frontend. //! -//! This is the only module that imports `maple_agent`. It owns a private -//! Tokio runtime and exposes an async facade plus an event stream. The UI -//! never touches the agent runtime directly, so this seam can later be moved -//! behind a process or socket boundary without touching UI code. +//! This owns the private Tokio runtime, the OpenSecret sign-in, billing, +//! audio, and the in-process agent service. Everything a client drives on +//! a host (tasks, projects, runs, integrations) goes through +//! [`maple_agent::host::HostBackend`] instead; [`AgentBackend::local_host`] +//! hands out the in-process implementation. A remote host implements the +//! same trait over the wire, so the UI never branches on where a host runs. // This module is the desktop frontend's boundary. A headless build (no // `desktop` feature) uses only a few entry points, so the rest is unused @@ -18,21 +20,17 @@ use std::process::Command; use std::sync::Arc; use maple_agent::agent::{ - AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentEventSink, AgentIntegration, - AgentIntegrationPermissionKind, AgentIntegrationPermissions, AgentProjectRootRegistration, - AgentProjectTrustStatus, AgentQueueControlRequest, AgentRenameSessionRequest, - AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, - AgentSessionSummary, AgentSetIntegrationEnabledRequest, AgentSetupIntegrationRequest, - AgentSlashCommand, AgentStartRequest, AgentSubagent, AgentTaskState, MapleAgentHostResources, - MapleAgentService, RecentProjectRoot, + AgentIntegrationPermissionKind, AgentIntegrationPermissions, AgentSetupIntegrationRequest, + AgentStartRequest, MapleAgentHostResources, MapleAgentService, }; +use maple_agent::host::{HostEvent, HostEventHub, LocalHostAuth, LocalHostBackend}; use maple_agent::maple_api::{ MapleApiAuthEventSink, MapleApiAuthRequest, MapleApiAuthSnapshot, MapleApiAuthState, + MapleApiSession, }; use maple_agent::open_secret_config::configured_pcr0_environment; use maple_sdk::OpenSecretClient; use tokio::runtime::Runtime; -use tokio::sync::mpsc; use uuid::Uuid; #[derive(Debug, Clone)] @@ -80,17 +78,6 @@ pub enum RestoreOutcome { Unavailable, } -/// Everything the chat screen can show before any network call: the saved -/// project root, the task list, the recent roots, and the newest task's -/// transcript. Read in one backend call so it all lands before a runtime -/// start takes the lifecycle lock for its network round trips. -pub struct LocalBootstrap { - pub project_root: Option, - pub sessions: Vec, - pub recent_roots: Vec, - pub latest: Option, -} - pub struct AgentBackend { runtime: Runtime, service: MapleAgentService, @@ -99,17 +86,14 @@ pub struct AgentBackend { persisted_auth: Arc, pending_oauth: PendingOAuthStore, client_id: Uuid, - event_rx: tokio::sync::Mutex>>, + /// The local host's event stream. The runtime emits into it; every + /// subscriber receives every event. + events: Arc, + /// One in-process host per signed-in account, created on first use. + local_hosts: std::sync::Mutex>>, billing: crate::billing::BillingClient, /// Cached billing JWT per user id. Replaced after a 401. billing_tokens: tokio::sync::Mutex>, - /// Open handle to the usage ledger DB; the context ring polls it every - /// second during a run, so it is not reopened per query. Shared with - /// the blocking task that runs each query. - usage_db: Arc>>, - /// Open handle to the app-owned tool summary store; keyed by account - /// scope path so a user switch reopens it. - summary_db: std::sync::Mutex>, /// True while a background credential restore runs. Calls that need a /// validated session wait on it (see `session_for`); local reads do not. restore_pending: ( @@ -141,19 +125,6 @@ fn client_id_from(configured: Option<&str>) -> Uuid { } } -struct ChannelEventSink(mpsc::UnboundedSender); - -impl AgentEventSink for ChannelEventSink { - fn emit(&self, event: &AgentServiceEvent) { - // The channel is unbounded, so sends only fail after the UI dropped - // the receiver (window closed). The runtime tolerates missing - // notifications for that case; surface it once for diagnosis. - if self.0.send(event.clone()).is_err() { - log::debug!("agent event receiver is gone; dropping further events"); - } - } -} - pub(crate) const APP_DIR_NAME: &str = "maple-agent"; /// Directory name used before the package rename. An existing directory is /// adopted in place on first start; see [`adopt_legacy_app_dirs`]. @@ -331,86 +302,11 @@ impl Drop for OAuthAttemptGuard<'_> { } } -impl AgentBackend { - /// The account scope (sha of the user id) used for on-disk layout. - pub fn account_scope(&self, user_id: &str) -> Option { - maple_agent::maple_api::account_scope(user_id).ok() - } -} - /// App configuration root (XDG-style), also used by the settings store. pub fn app_config_root() -> PathBuf { config_root() } -/// Path to the goose sessions database for one account scope. The agent -/// runtime owns and writes this file; the app only reads it. -pub fn account_session_db(account_scope: &str) -> PathBuf { - local_data_root() - .join("agent") - .join("accounts") - .join(account_scope) - .join("goose") - .join("data") - .join("sessions") - .join("sessions.db") -} - -/// Open the goose sessions database for reading. Returns `None` when the -/// file does not exist yet (read-only open never creates it). The busy -/// timeout covers the short locks goose takes for WAL checkpoints. -pub fn open_session_db_read_only(path: &std::path::Path) -> Option { - use rusqlite::OpenFlags; - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_URI; - let conn = match rusqlite::Connection::open_with_flags(path, flags) { - Ok(conn) => conn, - Err(error) => { - if path.exists() { - log::warn!("Cannot open session db {}: {error}", path.display()); - } - return None; - } - }; - if let Err(error) = conn.busy_timeout(std::time::Duration::from_secs(5)) { - log::warn!("Cannot set busy timeout on {}: {error}", path.display()); - } - Some(conn) -} - -/// Path to the app-owned store of model-written tool call summaries for -/// one account scope. Lives next to the agent data so it is removed with -/// the account. -fn account_summary_db(account_scope: &str) -> PathBuf { - local_data_root() - .join("agent") - .join("accounts") - .join(account_scope) - .join("tool_summaries.db") -} - -/// Open (and create) the tool summary store. -fn open_summary_db(path: &std::path::Path) -> Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("Cannot create {}: {error}", parent.display()))?; - } - let conn = rusqlite::Connection::open(path) - .map_err(|error| format!("Cannot open {}: {error}", path.display()))?; - conn.execute_batch( - "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; \ - CREATE TABLE IF NOT EXISTS tool_summaries ( \ - session_id TEXT NOT NULL, \ - item_id TEXT NOT NULL, \ - summary TEXT NOT NULL, \ - PRIMARY KEY (session_id, item_id) \ - );", - ) - .map_err(|error| format!("Cannot init {}: {error}", path.display()))?; - Ok(conn) -} - /// Root for configuration that may roam between machines. Mirrors Tauri's /// `app_config_dir`: `~/.config` on Linux, `~/Library/Application Support` /// on macOS, `%APPDATA%` on Windows. `XDG_CONFIG_HOME` overrides it on @@ -433,36 +329,19 @@ pub fn local_data_root() -> PathBuf { base.join(APP_DIR_NAME) } -fn env_dir(name: &str) -> Option { - std::env::var_os(name) - .map(PathBuf::from) - .filter(|path| path.is_absolute()) +/// The agent runtime's directory layout under this app's roots. Other +/// modules that keep per-account files beside the runtime's take the +/// account directory from here instead of rebuilding the layout. +pub fn agent_paths() -> maple_agent::agent::AgentPathLayout { + maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()) } -fn home_dir() -> Option { - std::env::var_os("HOME") +fn env_dir(name: &str) -> Option { + std::env::var_os(name) .map(PathBuf::from) .filter(|path| path.is_absolute()) } -/// Root the desktop app opens when the account has no saved root. The GUI -/// must not depend on the directory it was launched from: that is the job of -/// the `maple acp` command, not a windowed app started from a launcher. -fn fallback_project_root() -> Option { - home_dir().map(|path| path.to_string_lossy().to_string()) -} - -/// Root for a GUI start: the saved default when it still is a folder, else -/// the home directory. Never the process working directory. -fn gui_project_root(config: &maple_agent::agent::AgentConfig) -> Option { - config - .default_project_root - .as_deref() - .filter(|path| !path.trim().is_empty() && std::path::Path::new(path).is_dir()) - .map(str::to_owned) - .or_else(fallback_project_root) -} - #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] struct PersistedAuthRecord { user_id: String, @@ -662,6 +541,21 @@ impl PersistedAuthStore { } } +/// The local host's view of this backend's sign-in. Weak so the backend, +/// which owns the hosts, does not own itself through them. +struct BackendHostAuth(std::sync::Weak); + +#[async_trait::async_trait] +impl LocalHostAuth for BackendHostAuth { + async fn api_session(&self, user_id: &str) -> Result, String> { + let backend = self + .0 + .upgrade() + .ok_or_else(|| "The app is shutting down".to_string())?; + backend.session_for(user_id).await + } +} + struct PersistAuthSink { store: Arc, } @@ -681,20 +575,21 @@ impl MapleApiAuthEventSink for PersistAuthSink { } impl AgentBackend { - pub fn new(api_url: String, harness_instructions: String) -> Result { + pub fn new(api_url: String) -> Result { // Enforce the credential-bearing URL policy before any client is // built, including the login-time SDK client. let api_url = maple_agent::maple_api::validate_api_url(&api_url)?; - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let paths = - maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()); + let events = Arc::new(HostEventHub::default()); + let paths = agent_paths(); // Keeps ACP bridge credentials out of desktop tool environments. let default_tool_context = maple_agent::agent::default_tool_context_spec()?; + // The harness instructions are per account and reach the runtime + // through the local host once an account is bound. let service = MapleAgentService::new(MapleAgentHostResources::new( paths, - Arc::new(ChannelEventSink(event_tx)), + Arc::clone(&events) as Arc, default_tool_context, - harness_instructions, + maple_agent::host::DEFAULT_HARNESS_INSTRUCTIONS.to_string(), )); let runtime = Runtime::new().map_err(|error| format!("failed to start runtime: {error}"))?; @@ -713,20 +608,38 @@ impl AgentBackend { persisted_auth, pending_oauth: PendingOAuthStore::default(), client_id: configured_client_id(), - event_rx: tokio::sync::Mutex::new(Some(event_rx)), + events, + local_hosts: std::sync::Mutex::new(HashMap::new()), billing, billing_tokens: tokio::sync::Mutex::new(HashMap::new()), - usage_db: Arc::new(std::sync::Mutex::new(None)), - summary_db: std::sync::Mutex::new(None), restore_pending: tokio::sync::watch::channel(false), }) } - /// Replace the opening system prompt text for tasks this app hosts. - /// Applies to agents built after the call, so to a task's next fresh - /// agent, not to one already loaded. - pub fn set_harness_instructions(&self, harness_instructions: String) { - self.service.set_harness_instructions(harness_instructions); + /// A fresh subscription to the local host's events. The desktop event + /// pump takes one for the whole process. + pub fn subscribe_events(&self) -> tokio::sync::mpsc::UnboundedReceiver { + self.events.subscribe() + } + + /// The in-process host for `user_id`, created once per account. It + /// shares this backend's runtime and event stream. + pub fn local_host(self: &Arc, user_id: &str) -> Arc { + let mut hosts = self + .local_hosts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(host) = hosts.get(user_id) { + return Arc::clone(host); + } + let host = LocalHostBackend::new( + self.service.clone(), + user_id.to_string(), + Arc::new(BackendHostAuth(Arc::downgrade(self))), + Arc::clone(&self.events), + ); + hosts.insert(user_id.to_string(), Arc::clone(&host)); + host } pub fn api_url(&self) -> &str { @@ -748,11 +661,6 @@ impl AgentBackend { self.runtime.spawn(future) } - /// Take the backend event stream. Only the first caller receives it. - pub async fn take_events(&self) -> Option> { - self.event_rx.lock().await.take() - } - fn normalize_email(email: &str) -> Result { let email = email.trim().to_ascii_lowercase(); if email.is_empty() { @@ -833,12 +741,20 @@ impl AgentBackend { /// Restore a persisted session before the UI starts. Validates the /// credentials against the backend; returns the account id on success. pub fn restore_now(&self) -> Option { - match self.runtime.block_on(self.validate_persisted_auth()) { + match self.restore_outcome_now() { RestoreOutcome::Valid(user_id) => Some(user_id), RestoreOutcome::Rejected | RestoreOutcome::Unavailable => None, } } + /// [`Self::restore_now`] with the full outcome, for a command that + /// treats an unreachable server differently from a rejected sign-in. + /// `Rejected` also covers a missing sign-in; check + /// [`Self::saved_user_id`] first to tell them apart. + pub fn restore_outcome_now(&self) -> RestoreOutcome { + self.runtime.block_on(self.validate_persisted_auth()) + } + /// Validate the persisted credentials on the backend runtime while the /// UI already shows the account's local data. Calls that need the /// session wait for this to finish (see `session_for`). @@ -898,10 +814,7 @@ impl AgentBackend { /// The validated session for `user_id`, waiting first for a background /// credential restore that is still in flight. Local reads never call /// this; only backend requests that spend the credentials do. - async fn session_for( - &self, - user_id: &str, - ) -> Result, String> { + async fn session_for(&self, user_id: &str) -> Result, String> { self.wait_for_restore().await; self.auth.session_for(user_id).await } @@ -1161,7 +1074,7 @@ impl AgentBackend { return Err("Enter the confirmation code from the email".to_string()); } let session = self.session_for(user_id).await?; - self.stop_runtime(user_id).await?; + self.service.handle_for_user(user_id).await?.stop().await?; session .confirm_account_deletion(code, plaintext_secret) .await @@ -1329,7 +1242,7 @@ impl AgentBackend { async fn mint_billing_token( &self, - session: &Arc, + session: &Arc, user_id: &str, ) -> Result { let token = session @@ -1342,51 +1255,14 @@ impl AgentBackend { Ok(token) } - pub async fn start_runtime( - &self, - user_id: &str, - request: Option, - ) -> Result { - let handle = self.service.handle_for_user(user_id).await?; - let session = self.session_for(user_id).await?; - // The agent falls back to the process working directory when no root - // is given. That is right for `maple acp`, not for the GUI: pick the - // saved root or the home directory instead. - let request = match request { - Some(AgentStartRequest { - project_root: None, - model, - mode, - }) => { - let config = handle.load_config().await?; - Some(AgentStartRequest { - project_root: gui_project_root(&config), - model, - mode, - }) - } - other => other, - }; - // A wedged enclave connection must surface as an error, not an - // eternal spinner. - tokio::time::timeout( - std::time::Duration::from_secs(60), - handle.start(session, request), - ) - .await - .map_err(|_| "Runtime start timed out. Check your connection and retry.".to_string())? - } - - pub async fn stop_runtime(&self, user_id: &str) -> Result { - self.service.handle_for_user(user_id).await?.stop().await - } - /// Serve ACP on stdin/stdout for `user_id` until the peer closes stdin. /// Starts the runtime first, rooted at the process working directory, /// and stops it when the connection ends. #[cfg(feature = "acp")] - pub fn run_acp_stdio(&self, user_id: &str) -> Result<(), String> { + pub fn run_acp_stdio(self: &Arc, user_id: &str) -> Result<(), String> { + let host = self.local_host(user_id); self.runtime.block_on(async { + host.apply_saved_harness().await?; let handle = self.service.handle_for_user(user_id).await?; let session = self.session_for(user_id).await?; // Start the runtime concurrently instead of before the handshake: @@ -1405,322 +1281,6 @@ impl AgentBackend { }) } - /// Roots recently used by this account, most recent first. - pub async fn recent_project_roots( - &self, - user_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_recent_project_roots() - .await - } - - /// Register and select the default root for new tasks. - /// - /// The account runtime is deliberately not restarted: existing tasks own - /// their persisted working directories and may keep running under other - /// roots while the UI moves between projects. - /// - /// Choosing a folder does not record a trust decision. Home and the - /// process launch directory are already trusted with no saved answer; - /// every other root keeps `None` until the one-time prompt. A saved - /// "do not trust" answer stays. - pub async fn select_project_root( - &self, - user_id: &str, - path: String, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .save_recent_project_root(path) - .await - } - - pub async fn list_sessions( - &self, - user_id: &str, - project_root: Option, - ) -> Result, String> { - let mut sessions = self - .service - .handle_for_user(user_id) - .await? - .list_sessions(project_root) - .await?; - // Tasks that an ACP client created belong to that client's UI, not - // to the desktop task list. - sessions.retain(|session| !session.acp); - Ok(sessions) - } - - /// Read everything the chat screen can show without the network: the - /// saved project root, the task list, the recent roots, and the newest - /// task's transcript. Call it before `start_runtime`: the runtime start - /// holds the lifecycle lock across its network round trips, and these - /// reads would queue behind it. - pub async fn local_bootstrap(&self, user_id: &str) -> Result { - let handle = self.service.handle_for_user(user_id).await?; - let config = handle.load_config().await?; - let project_root = gui_project_root(&config); - let mut sessions = handle.list_sessions(None).await?; - // Tasks that an ACP client created belong to that client's UI, not - // to the desktop task list. - sessions.retain(|session| !session.acp); - let recent_roots = handle - .list_recent_project_roots() - .await? - .into_iter() - .map(|root| root.path) - .collect(); - // Same choice the screen's auto-select makes: the newest unarchived - // task under the root that the runtime will start in. An empty task - // is a draft an older build persisted; the screen's own new-task - // draft stands in for it. - let latest_id = sessions - .iter() - .find(|session| { - session.state != AgentTaskState::Archived - && session.message_count > 0 - && Some(&session.project_root) == project_root.as_ref() - }) - .map(|session| session.id.clone()); - let latest = match latest_id { - Some(id) => handle.load_session(id).await.ok(), - None => None, - }; - Ok(LocalBootstrap { - project_root, - sessions, - recent_roots, - latest, - }) - } - - pub async fn create_session( - &self, - user_id: &str, - request: Option, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .create_session(request) - .await - } - - pub async fn load_session( - &self, - user_id: &str, - session_id: &str, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .load_session(session_id.to_string()) - .await - } - - /// Move a task between active, settled, and archived. The runtime - /// refuses to settle or archive a task while it runs. - pub async fn set_session_state( - &self, - user_id: &str, - session_id: &str, - state: AgentTaskState, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_session_state(session_id.to_string(), state) - .await - } - - /// Delete a task and everything stored for it. The runtime refuses - /// while the task runs or an external surface holds it. - pub async fn delete_session(&self, user_id: &str, session_id: &str) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .delete_session(session_id.to_string()) - .await - } - - /// Drop a root from the recent list. `fallback` becomes the runtime - /// root when the removed one was current. - pub async fn remove_project_root( - &self, - user_id: &str, - path: String, - fallback: Option, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .remove_project_root(path, fallback) - .await - .map(|_| ()) - } - - pub async fn rename_session( - &self, - user_id: &str, - session_id: &str, - title: String, - ) -> Result { - let handle = self.service.handle_for_user(user_id).await?; - let session = self.session_for(user_id).await?; - handle - .rename_session( - session, - AgentRenameSessionRequest { - session_id: session_id.to_string(), - title, - }, - ) - .await - } - - /// Whether the project at `path` has skills or other guidance that - /// need a trust decision, and what the saved decision is. - pub async fn project_trust( - &self, - user_id: &str, - path: String, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .get_project_trust(path) - .await - } - - pub async fn set_project_trust( - &self, - user_id: &str, - path: String, - trusted: bool, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_project_trust(path, trusted) - .await - } - - /// Drop a message that waits behind the active run. - pub async fn cancel_queued_message( - &self, - user_id: &str, - session_id: &str, - queue_id: &str, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .cancel_queued_message(AgentQueueControlRequest { - session_id: session_id.to_string(), - queue_id: queue_id.to_string(), - }) - .await - } - - /// Hold a queued message while the user edits it: it is not promoted - /// into the run until the edit ends. - pub async fn begin_queued_message_edit( - &self, - user_id: &str, - session_id: &str, - queue_id: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .begin_queued_message_edit(AgentQueueControlRequest { - session_id: session_id.to_string(), - queue_id: queue_id.to_string(), - }) - .await - } - - /// Release a queued message held by [`Self::begin_queued_message_edit`] - /// without changing it. - pub async fn end_queued_message_edit( - &self, - user_id: &str, - session_id: &str, - queue_id: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .end_queued_message_edit(AgentQueueControlRequest { - session_id: session_id.to_string(), - queue_id: queue_id.to_string(), - }) - .await - } - - pub async fn send_message( - &self, - user_id: &str, - request: AgentSendMessageRequest, - ) -> Result { - let run_id = self - .service - .handle_for_user(user_id) - .await? - .send_message(request) - .await? - .run_id; - Ok(run_id) - } - - /// Bytes of an image the user attached to a message in `session_id`. - pub async fn read_image_attachment( - &self, - user_id: &str, - session_id: &str, - attachment_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .read_image_attachment(session_id.to_string(), attachment_id.to_string()) - .await - } - - pub async fn cancel_run(&self, user_id: &str, run_id: &str) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .cancel_desktop_run(run_id.to_string()) - .await - } - - pub async fn available_model_ids(&self, user_id: &str) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .available_model_ids() - .await - } - - /// Catalog vision flag for a model; None when unknown. - pub async fn model_supports_vision( - &self, - user_id: &str, - model: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .model_supports_vision(model) - .await - } - /// Which voice endpoints the account offers. pub async fn audio_capabilities( &self, @@ -1757,73 +1317,16 @@ impl AgentBackend { .await } - /// MCP servers configured for the account, with the session's enabled - /// state for each. - pub async fn list_session_mcp_servers( + /// Open the settings pane that grants the permission a curated + /// integration still needs, after [`Self::begin_integration_setup`] + /// ran. A local capability: it opens a pane on this machine's screen, + /// so it applies to the local host only. Persist the integration with + /// `HostBackend::setup_integration` afterwards. + pub async fn open_integration_setup_settings( &self, - user_id: &str, - session_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_session_mcp_servers(session_id.to_string()) - .await - } - - pub async fn set_session_mcp_server_enabled( - &self, - user_id: &str, - session_id: &str, - name: &str, - kind: maple_agent::agent::AgentSessionIntegrationKind, - enabled: bool, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .set_session_mcp_server_enabled(maple_agent::agent::AgentSetSessionMcpServerRequest { - session_id: session_id.to_string(), - name: name.to_string(), - kind, - enabled, - }) - .await - } - - pub async fn list_mcp_servers( - &self, - user_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_mcp_servers() - .await - } - - pub async fn list_integrations(&self, user_id: &str) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_integrations() - .await - } - - pub async fn set_integration_enabled( - &self, - user_id: &str, - id: &str, - enabled: bool, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .set_integration_enabled(AgentSetIntegrationEnabledRequest { - id: id.to_string(), - enabled, - }) - .await + permissions: &AgentIntegrationPermissions, + ) -> Result<(), String> { + open_integration_setup_settings(permissions).await } /// Start a curated integration's host-owned permission flow from the UI @@ -1837,335 +1340,6 @@ impl AgentBackend { }) } - /// Persist a curated integration after its host-owned permission flow. - pub async fn setup_integration( - &self, - user_id: &str, - id: &str, - permissions: AgentIntegrationPermissions, - ) -> Result, String> { - open_integration_setup_settings(&permissions).await?; - self.service - .handle_for_user(user_id) - .await? - .setup_integration(AgentSetupIntegrationRequest { id: id.to_string() }) - .await - } - - pub async fn save_mcp_servers( - &self, - user_id: &str, - servers: Vec, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .save_mcp_servers(servers) - .await - } - - /// Turn the web tools on or off for a session (next turn onward). - pub async fn set_session_web_enabled( - &self, - user_id: &str, - session_id: &str, - enabled: bool, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_session_web_enabled(maple_agent::agent::AgentSetSessionWebRequest { - session_id: session_id.to_string(), - enabled, - }) - .await - } - - /// Set the permission policy for a session: "smart_approve" asks for - /// each gated tool, "auto" approves everything (bypass). - pub async fn set_permission_mode( - &self, - user_id: &str, - session_id: &str, - mode: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .set_permission_mode(maple_agent::agent::AgentPermissionModeRequest { - session_id: session_id.to_string(), - mode: mode.to_string(), - }) - .await - } - - /// Compact a session's history now; reload the session afterwards. - pub async fn compact_session(&self, user_id: &str, session_id: &str) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .compact_session(session_id.to_string()) - .await - } - - /// The subagents still working for a task. A task whose run ended can - /// still have a background subagent; this rebuilds the card for it. - pub async fn session_subagents( - &self, - user_id: &str, - session_id: &str, - ) -> Result, String> { - Ok(self - .service - .handle_for_user(user_id) - .await? - .session_subagents(session_id) - .await) - } - - /// Interrupt an external agent (Codex) from its row. The agent keeps - /// its thread so the task can continue it later. - pub async fn cancel_external_agent( - &self, - user_id: &str, - session_id: &str, - agent_id: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .cancel_external_agent(session_id, agent_id) - .await - } - - /// Slash commands (installed skills) for a working directory. Filesystem - /// scan, so it runs on a blocking thread. - pub async fn list_slash_commands( - &self, - user_id: &str, - working_dir: Option, - ) -> Result, String> { - let service = self.service.clone(); - let user_id = user_id.to_string(); - tokio::task::spawn_blocking(move || { - service.list_slash_commands(Some(&user_id), working_dir.as_deref()) - }) - .await - .map_err(|error| format!("Slash command scan failed: {error}")) - } - - /// Expand `/command args` into the skill prompt; `None` when the command - /// matches no skill. - pub async fn resolve_slash_command( - &self, - working_dir: Option, - command: String, - args: String, - ) -> Result, String> { - let service = self.service.clone(); - tokio::task::spawn_blocking(move || { - service.resolve_slash_command(working_dir.as_deref(), &command, &args) - }) - .await - .map_err(|error| format!("Slash command resolve failed: {error}"))? - } - - /// One-line summary of a completed tool call from the cheap title model. - pub async fn summarize_tool_call( - &self, - user_id: &str, - session_id: &str, - tool_name: String, - input: Option, - output_text: String, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .summarize_tool_call(session_id, &tool_name, input.as_ref(), &output_text) - .await - } - - /// One-line summary of a finished thinking block from the cheap title - /// model. - pub async fn summarize_thinking( - &self, - user_id: &str, - session_id: &str, - thinking_text: String, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .summarize_thinking(session_id, &thinking_text) - .await - } - /// Stream the answer to a `/btw` side question; see - /// `AgentRuntimeHandle::ask_side_question`. - pub async fn ask_side_question( - &self, - user_id: &str, - session_id: &str, - request_id: String, - prior: Vec, - question: String, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .ask_side_question(session_id, request_id, prior, question) - .await - } - /// Run `f` against the summary store of `user_id`. Blocking: call from - /// `spawn_blocking`. - fn with_summary_db( - &self, - user_id: &str, - f: impl FnOnce(&rusqlite::Connection) -> Result, - ) -> Result { - let scope = self - .account_scope(user_id) - .ok_or_else(|| "No account scope".to_string())?; - let db = account_summary_db(&scope); - let mut guard = self.summary_db.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().map(|(path, _)| path != &db).unwrap_or(true) { - *guard = Some((db.clone(), open_summary_db(&db)?)); - } - f(&guard.as_ref().expect("summary db opened above").1) - } - - /// Stored summaries for one session, keyed by timeline item id. - /// Blocking. - pub fn load_tool_summaries_blocking( - &self, - user_id: &str, - session_id: &str, - ) -> Result, String> { - self.with_summary_db(user_id, |conn| { - let mut stmt = conn - .prepare("SELECT item_id, summary FROM tool_summaries WHERE session_id = ?1") - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map([session_id], |row| Ok((row.get(0)?, row.get(1)?))) - .map_err(|error| error.to_string())?; - rows.collect::, _>>() - .map_err(|error| error.to_string()) - }) - } - - /// Persist one summary. Blocking. - pub fn store_tool_summary_blocking( - &self, - user_id: &str, - session_id: &str, - item_id: &str, - summary: &str, - ) -> Result<(), String> { - self.with_summary_db(user_id, |conn| { - conn.execute( - "INSERT OR REPLACE INTO tool_summaries (session_id, item_id, summary) \ - VALUES (?1, ?2, ?3)", - [session_id, item_id, summary], - ) - .map(|_| ()) - .map_err(|error| error.to_string()) - }) - } - - /// Latest context usage for a session from the goose usage ledger: - /// (context tokens, context limit). The limit comes from the model - /// catalog for the selected model; MAPLE_CONTEXT_LIMIT is a manual - /// override; 200k is the fallback when the catalog lacks the model. - pub async fn context_usage( - &self, - user_id: &str, - session_id: &str, - model: Option<&str>, - ) -> Result, String> { - let Some(scope) = self.account_scope(user_id) else { - return Ok(None); - }; - let limit: i64 = match std::env::var("MAPLE_CONTEXT_LIMIT") - .ok() - .and_then(|value| value.parse().ok()) - { - Some(limit) if limit > 0 => limit, - _ => match model { - Some(model) => self - .service - .handle_for_user(user_id) - .await? - .context_limit_for_model(model) - .await? - .unwrap_or(200_000), - None => 200_000, - }, - }; - // SQLite is synchronous; keep it off the async workers. - let db = crate::backend::account_session_db(&scope); - let usage_db = self.usage_db.clone(); - let session_id = session_id.to_string(); - let tokens = tokio::task::spawn_blocking(move || { - let mut guard = usage_db.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().map(|(path, _)| path != &db).unwrap_or(true) { - let conn = crate::backend::open_session_db_read_only(&db)?; - *guard = Some((db, conn)); - } - let conn = &guard.as_ref().expect("usage db opened above").1; - conn.query_row( - "SELECT COALESCE(input_tokens,0) + COALESCE(cache_read_tokens,0) \ - + COALESCE(cache_write_tokens,0) FROM usage_ledger \ - WHERE session_id = ?1 AND is_compaction = 0 \ - ORDER BY id DESC LIMIT 1", - [session_id], - |row| row.get::<_, i64>(0), - ) - .ok() - }) - .await - .map_err(|error| format!("Context usage query failed: {error}"))?; - Ok(tokens.map(|tokens| (tokens, limit))) - } - - /// Deliver the user's answer to an ask_user question. Returns false - /// when no question was pending. - pub async fn answer_question( - &self, - user_id: &str, - request_id: &str, - answer: String, - ) -> Result { - let _service = self.service.clone(); - let request_id = request_id.to_string(); - self.service - .handle_for_user(user_id) - .await? - .answer_question_via_handle(&request_id, answer) - .await - } - - pub async fn permission_respond( - &self, - user_id: &str, - session_id: &str, - request_id: &str, - allow: bool, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .permission_respond(maple_agent::agent::AgentPermissionResponse { - session_id: session_id.to_string(), - request_id: request_id.to_string(), - decision: if allow { - "allow_once".to_string() - } else { - "deny_once".to_string() - }, - }) - .await - } - /// Standard start request for this app: the saved project root (see /// `start_runtime`) with the configured model and the SmartApprove policy. pub fn default_start_request(&self) -> AgentStartRequest { @@ -2176,25 +1350,10 @@ impl AgentBackend { } } - /// Persist the UI's model choice as the account's default model. - pub async fn save_default_model(&self, user_id: &str, model: String) -> Result<(), String> { - let handle = self.service.handle_for_user(user_id).await?; - let mut config = handle.load_config().await?; - config.default_model = model; - handle.save_config(config).await - } - /// Model the UI should select initially: MAPLE_MODEL when set. pub fn configured_model(&self) -> Option { std::env::var("MAPLE_MODEL").ok() } - - /// The account's saved default model, if any. - pub async fn saved_model(&self, user_id: &str) -> Option { - let handle = self.service.handle_for_user(user_id).await.ok()?; - let config = handle.load_config().await.ok()?; - Some(config.default_model).filter(|model| !model.is_empty()) - } } const MACOS_ACCESSIBILITY_SETTINGS_URL: &str = diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 709bb708b..a832bcdfe 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -34,6 +34,10 @@ struct MapleApp { backend: Arc, screen: Screen, user_id: Option, + /// Connections to the account's saved hosts; lives with the chat. + hosts: Option>, + /// This window's host role for the signed-in account. + hosting: Option>, /// The chat screen is parked while settings is open so Back returns to /// it with its state intact. parked_chat: Option>, @@ -48,13 +52,13 @@ struct MapleApp { impl MapleApp { /// Forward a batch of backend service events to the chat screen when /// one exists. One batch is one render, however many events arrived. - fn handle_service_events( + fn handle_host_events( &mut self, - events: Vec, + events: Vec, cx: &mut Context, ) { if let Screen::Chat(chat) = &self.screen { - chat.update(cx, |chat, cx| chat.handle_service_events(events, cx)); + chat.update(cx, |chat, cx| chat.handle_host_events(events, cx)); } } @@ -87,6 +91,12 @@ impl MapleApp { fn show_login(&mut self, cx: &mut Context) { self.user_id = None; self.parked_chat = None; + if let Some(hosts) = self.hosts.take() { + hosts.shutdown(); + } + if let Some(hosting) = self.hosting.take() { + hosting.stop(); + } if matches!(self.screen, Screen::Login(_)) { return; } @@ -115,7 +125,65 @@ impl MapleApp { crate::startup_elapsed() ); let backend = self.backend.clone(); - let chat = cx.new(|cx| ChatScreen::new(backend, user_id.clone(), cx)); + let host = backend.local_host(&user_id); + // Session defaults an older version kept in settings.json: the + // launch moved them for the saved account, but a user signed out + // at the upgrade binds an account here first. Idempotent, and done + // once per process: the in-memory copy is cleared below. + let legacy = self.settings.legacy_session_defaults(); + if !legacy.is_empty() { + let host = host.clone(); + backend.spawn(async move { + crate::adopt_legacy_session_defaults_into(&host, legacy).await; + }); + self.settings.legacy = Default::default(); + } + let chat = cx.new(|cx| ChatScreen::new(backend.clone(), host, user_id.clone(), cx)); + // Remote hosts: connect to every saved one and pump what they + // report into the chat screen, batched like the local events. + if let Some(previous) = self.hosts.take() { + previous.shutdown(); + } + match crate::remote::client::start_manager(&backend, &user_id) { + Ok((manager, mut events)) => { + self.hosts = Some(manager); + let chat = chat.downgrade(); + cx.spawn(async move |_app, cx| { + while let Some(event) = events.recv().await { + let mut batch = vec![event]; + while let Ok(next) = events.try_recv() { + batch.push(next); + if batch.len() >= 256 { + break; + } + } + if chat + .update(cx, |chat, cx| chat.handle_manager_events(batch, cx)) + .is_err() + { + break; + } + } + }) + .detach(); + } + Err(error) => log::warn!("remote hosts are unavailable: {error}"), + } + // The host role: listen only when the setting says so. + if let Some(previous) = self.hosting.take() { + previous.stop(); + } + let hosting = Arc::new(crate::remote::host::HostingController::new( + backend.clone(), + backend.local_host(&user_id), + user_id.clone(), + )); + if self.settings.allow_remote_connections { + // Binds on the backend runtime; the controller reports the + // outcome to Settings and the log. + backend.spawn(hosting.start(crate::remote::DEFAULT_LISTEN)); + } + self.hosting = Some(hosting); // The release check may have finished while the login screen was // up; the banner must not be lost with it. if let Some(info) = crate::update::available() { @@ -135,10 +203,25 @@ impl MapleApp { }; let backend = self.backend.clone(); let user_id = self.user_id.clone().unwrap_or_default(); + let host = backend.local_host(&user_id); + let hosts = chat.read(cx).connected_hosts(); + let manager = self.hosts.clone(); + let hosting = self.hosting.clone(); let settings = self.settings.clone(); let shortcut_snapshot = self.shortcuts.snapshot(); let screen = cx.new(|cx| { - SettingsScreen::new(backend, user_id, settings, shortcut_snapshot, section, cx) + SettingsScreen::new( + backend, + host, + hosts, + manager, + hosting, + user_id, + settings, + shortcut_snapshot, + section, + cx, + ) }); cx.subscribe( &screen, @@ -371,7 +454,7 @@ pub fn run() { // Name the resolved file so a launcher with its own XDG_CONFIG_HOME // makes itself visible: settings that look unsaved usually live in a // different root than the one this launch reads. - let startup_settings = crate::settings::load_settings(); + let mut startup_settings = crate::settings::load_settings(); log::debug!( "startup: settings loaded from {} at {} ms", crate::backend::app_config_root() @@ -380,13 +463,16 @@ pub fn run() { crate::startup_elapsed() ); let backend = Arc::new( - AgentBackend::new( - crate::configured_api_url(), - startup_settings.effective_harness_instructions(), - ) - .expect("failed to initialize agent backend"), + AgentBackend::new(crate::configured_api_url()).expect("failed to initialize agent backend"), ); log::debug!("startup: backend ready at {} ms", crate::startup_elapsed()); + // Session defaults an older version kept in settings.json belong to the + // account config now. Move them before the chat screen reads them. With + // no saved account they wait for the sign-in (see `open_chat`). + if let Some(user_id) = backend.saved_user_id() { + crate::adopt_legacy_session_defaults(&backend, &user_id); + startup_settings.legacy = Default::default(); + } gpui_platform::application() .with_assets(crate::assets::Assets) @@ -476,6 +562,8 @@ pub fn run() { backend: root_backend, screen: Screen::Restoring, user_id: None, + hosts: None, + hosting: None, parked_chat: None, settings: root_settings, shortcuts: shortcut_runtime, @@ -558,12 +646,8 @@ pub fn run() { // The event pump runs once for the whole process and routes events to // whichever screen is active. It exits when the root entity is gone. - let (spawn_backend, take_backend) = (backend.clone(), backend.clone()); - let rx = spawn_backend.spawn(async move { take_backend.take_events().await }); + let mut rx = backend.subscribe_events(); cx.spawn(async move |cx| { - let Some(mut rx) = rx.await.ok().flatten() else { - return; - }; while let Some(event) = rx.recv().await { // Drain whatever else is queued so a burst of streaming // chunks costs one update and one render, not one each. @@ -576,7 +660,7 @@ pub fn run() { } // This gpui's entity update is infallible; the pump ends // with the channel instead. - root.update(cx, |app, cx| app.handle_service_events(batch, cx)); + root.update(cx, |app, cx| app.handle_host_events(batch, cx)); } }) .detach(); diff --git a/apps/maple-agent/app/src/env.rs b/apps/maple-agent/app/src/env.rs index aab16da24..fdeade1d8 100644 --- a/apps/maple-agent/app/src/env.rs +++ b/apps/maple-agent/app/src/env.rs @@ -2,10 +2,28 @@ //! trimmed, and an empty value counts as unset so a stray `NAME=` in a //! launcher does not override a default with nothing. -// A headless build (no `desktop` feature) has no update check, the only -// caller of `env_flag`. +// A headless build (no `desktop` feature) leaves some helpers without a +// caller: `env_flag` belongs to the update check, `hostname` to the host +// and client roles the build may lack. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] +/// The package version of this binary. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The git revision `build.rs` baked in (`abc1234`, or `abc1234-dirty`), +/// or `None` when the build ran outside a git checkout. Sent to peers so +/// two builds of one version can be told apart. +pub fn build_hash() -> Option<&'static str> { + option_env!("MAPLE_GIT_HASH").filter(|hash| !hash.is_empty() && *hash != "unknown") +} + +/// The `--version` string: package version plus the git revision, so a +/// running binary can be matched back to a checkout. `unknown` stands in +/// for a missing revision. +pub fn version_string() -> String { + maple_remote::wire::version_label(APP_VERSION, Some(build_hash().unwrap_or("unknown"))) +} + /// The trimmed value of `name`, or `None` when unset or blank. pub fn env_string(name: &str) -> Option { std::env::var(name) @@ -14,6 +32,40 @@ pub fn env_string(name: &str) -> Option { .filter(|value| !value.is_empty()) } +/// This machine's name, for hosts and devices to show each other. +/// `HOSTNAME` in the environment overrides what the system reports. +pub fn hostname() -> String { + env_string("HOSTNAME") + .or_else(system_hostname) + .unwrap_or_else(|| "maple".to_string()) +} + +/// The name the operating system reports, or `None` when it has none. +#[cfg(unix)] +fn system_hostname() -> Option { + // `gethostname` truncates to the buffer without a terminator when the + // name is longer; 256 exceeds every platform's HOST_NAME_MAX. + let mut buffer = [0u8; 256]; + // SAFETY: the buffer is valid for writes of its full length, and the + // call writes at most that many bytes. + let status = + unsafe { libc::gethostname(buffer.as_mut_ptr() as *mut libc::c_char, buffer.len()) }; + if status != 0 { + return None; + } + let end = buffer + .iter() + .position(|byte| *byte == 0) + .unwrap_or(buffer.len()); + let name = String::from_utf8_lossy(&buffer[..end]).trim().to_string(); + (!name.is_empty()).then_some(name) +} + +#[cfg(not(unix))] +fn system_hostname() -> Option { + env_string("COMPUTERNAME") +} + /// Whether `name` is set to `1`, `true`, or `yes` (case-insensitive). pub fn env_flag(name: &str) -> bool { env_string(name).is_some_and(|value| { @@ -46,6 +98,18 @@ mod tests { out } + #[test] + fn version_string_names_the_package_version_and_the_revision() { + let version = version_string(); + match build_hash() { + Some(hash) => { + assert_ne!(hash, "unknown"); + assert_eq!(version, format!("{APP_VERSION} ({hash})")); + } + None => assert_eq!(version, format!("{APP_VERSION} (unknown)")), + } + } + #[test] fn string_trims_and_drops_blank() { let read = || env_string("MAPLE_TEST_STRING"); @@ -68,4 +132,13 @@ mod tests { } assert!(!with_var("MAPLE_TEST_FLAG", None, read)); } + + #[test] + fn hostname_prefers_the_override_and_never_comes_back_empty() { + assert_eq!(with_var("HOSTNAME", Some(" box "), hostname), "box"); + let system = with_var("HOSTNAME", None, hostname); + assert!(!system.is_empty()); + assert_eq!(system, system.trim()); + assert!(!system.contains('\0')); + } } diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index f2d7d31a1..2ba52cd9c 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -17,6 +17,8 @@ mod keymap; mod notify; #[cfg(feature = "desktop")] mod platform; +mod remote; +mod serve; mod settings; #[cfg(feature = "desktop")] mod shortcuts; @@ -37,13 +39,11 @@ fn disabled_mode(mode: &str, feature: &str) -> ! { /// Command line for the binary. With no subcommand it opens the desktop /// window; `acp` and `proxy` run headless services. -/// The `--version` string: package version plus the git revision baked in -/// by `build.rs`, so a running binary can be matched back to a checkout. -/// Clap wants a `&'static str` and both inputs are compile-time constants, -/// but `format!` is still runtime, hence the leak of one small string. +/// The `--version` string from [`env::version_string`]. Clap wants a +/// `&'static str` and both inputs are compile-time constants, but +/// `format!` is still runtime, hence the leak of one small string. fn version_string() -> &'static str { - let hash = option_env!("MAPLE_GIT_HASH").unwrap_or("unknown"); - Box::leak(format!("{} ({})", env!("CARGO_PKG_VERSION"), hash).into_boxed_str()) + Box::leak(env::version_string().into_boxed_str()) } #[derive(Debug, Parser)] @@ -71,6 +71,8 @@ enum Mode { Proxy(ProxyArgs), /// Sign in with email and password and save the session for `acp`. Login(LoginArgs), + /// Publish this machine's agent runtime to paired Maple clients. + Serve(serve::ServeArgs), } /// Settings for `maple-agent login`. The password is always prompted for @@ -195,6 +197,22 @@ fn main() { std::process::exit(1); } } + Some(Mode::Serve(args)) => { + #[cfg(not(feature = "serve"))] + { + let _ = args; + disabled_mode("serve", "serve"); + } + #[cfg(feature = "serve")] + { + init_logging(LogOutput::FileAndStderr); + if let Err(error) = serve::run(args) { + log::error!("{error}"); + eprintln!("{error}"); + std::process::exit(1); + } + } + } None => { #[cfg(feature = "desktop")] desktop::run(); @@ -286,19 +304,54 @@ fn configured_api_url() -> String { /// the desktop app does not need to run. #[cfg(feature = "acp")] fn run_acp() -> Result<(), String> { - let harness_instructions = settings::load_settings().effective_harness_instructions(); - let backend = AgentBackend::new(configured_api_url(), harness_instructions)?; + let backend = std::sync::Arc::new(AgentBackend::new(configured_api_url())?); let user_id = backend.restore_now().ok_or_else(|| { "No saved Maple sign-in. Open the desktop app and sign in first.".to_string() })?; + adopt_legacy_session_defaults(&backend, &user_id); backend.run_acp_stdio(&user_id) } +/// Session defaults an older version kept in settings.json move into the +/// account config the first time an account is bound. The window and the +/// `acp` and `serve` commands bind an account, so all of them run this +/// before the runtime reads its config; the host ignores values the config +/// already holds, and the next settings save drops the old keys. +#[cfg(any(feature = "desktop", feature = "acp", feature = "serve"))] +pub(crate) fn adopt_legacy_session_defaults(backend: &std::sync::Arc, user_id: &str) { + let legacy = settings::load_settings().legacy_session_defaults(); + if legacy.is_empty() { + return; + } + let host = backend.local_host(user_id); + backend + .runtime_handle() + .block_on(adopt_legacy_session_defaults_into(&host, legacy)); +} + +/// The migration itself, for a caller already on the backend runtime: the +/// window runs it again after a sign-in, since a user who was signed out +/// at the upgrade has no saved account for the launch-time pass to bind. +/// Idempotent: `migrate_session_defaults` keeps what the config holds. +#[cfg(any(feature = "desktop", feature = "acp", feature = "serve"))] +pub(crate) async fn adopt_legacy_session_defaults_into( + host: &maple_agent::host::LocalHostBackend, + legacy: maple_agent::host::LegacySessionDefaults, +) { + if legacy.is_empty() { + return; + } + match host.migrate_session_defaults(legacy).await { + Ok(()) => settings::update_settings_in_background(|_| {}), + Err(error) => log::warn!("session defaults were not migrated: {error}"), + } +} + /// `maple-agent login`: sign in from a terminal. The saved session is the /// same one the desktop app writes, so `maple-agent acp` can run on a /// machine that never opened the window. fn run_login(args: LoginArgs) -> Result<(), String> { - let backend = AgentBackend::new(configured_api_url(), String::new())?; + let backend = AgentBackend::new(configured_api_url())?; if let Some(user_id) = backend.saved_user_id() { eprintln!("A Maple sign-in is already saved; signing in again replaces it."); log::info!("login replaces the saved session for account {user_id}"); @@ -389,7 +442,7 @@ fn init_logging(output: LogOutput) { })); log::info!( "maple-agent {} starting; log file: {}", - env!("CARGO_PKG_VERSION"), + env::version_string(), log_dir.join("maple-agent.log").display() ); for note in ADOPTED_APP_DIRS.get().into_iter().flatten() { diff --git a/apps/maple-agent/app/src/remote/client.rs b/apps/maple-agent/app/src/remote/client.rs new file mode 100644 index 000000000..02d71f43f --- /dev/null +++ b/apps/maple-agent/app/src/remote/client.rs @@ -0,0 +1,65 @@ +//! The desktop app as a client of remote hosts. +//! +//! Builds the connection manager for the signed-in account: this device's +//! static key, the saved hosts file under the account, and the hello every +//! connection sends. The manager runs on the backend's Tokio runtime and +//! reports through one channel that the desktop shell pumps into the chat +//! screen. + +use std::sync::Arc; + +use maple_remote::client::ClientConfig; +use maple_remote::hosts::HostsStore; +use maple_remote::keys::StaticKey; +use maple_remote::manager::{HostManager, HostManagerEvent}; +use maple_remote::wire::{ClientHello, DeviceInfo, PROTOCOL_VERSION, features}; +use tokio::sync::mpsc; + +use crate::backend::AgentBackend; + +/// This device's static key, generated on first use. It sits beside the +/// host key: one machine, two roles. +pub fn device_key() -> Result { + StaticKey::load_or_create(&super::remote_dir()?.join("device_key.json")) +} + +/// The saved hosts of one account, in the account's local data directory +/// so removing the account removes them. +fn hosts_store(user_id: &str) -> Result { + let dir = maple_agent::agent::account_local_data_dir(&crate::backend::agent_paths(), user_id)?; + Ok(HostsStore::new(dir.join("hosts.json"))) +} + +/// The hello this device sends to every host. +fn client_hello(device: &StaticKey, user_id: &str) -> Result { + Ok(ClientHello { + protocol: PROTOCOL_VERSION, + app_version: crate::env::APP_VERSION.to_string(), + build: crate::env::build_hash().map(str::to_string), + pcr_environment: format!( + "{:?}", + maple_agent::open_secret_config::configured_pcr0_environment()? + ), + features: features(), + device: DeviceInfo { + public_key: device.public_id(), + name: crate::env::hostname(), + user_id: Some(user_id.to_string()), + }, + }) +} + +/// Start connecting to every saved host of `user_id`. Connectors run on +/// the backend runtime; events arrive on the returned channel. +pub fn start_manager( + backend: &Arc, + user_id: &str, +) -> Result<(Arc, mpsc::UnboundedReceiver), String> { + let device = device_key()?; + let hello = client_hello(&device, user_id)?; + let store = Arc::new(hosts_store(user_id)?); + let (manager, events) = HostManager::new(device, hello, store, ClientConfig::default()); + let _runtime = backend.runtime_handle().enter(); + manager.start(); + Ok((manager, events)) +} diff --git a/apps/maple-agent/app/src/remote/host.rs b/apps/maple-agent/app/src/remote/host.rs new file mode 100644 index 000000000..9d4d9246f --- /dev/null +++ b/apps/maple-agent/app/src/remote/host.rs @@ -0,0 +1,419 @@ +//! This machine as a host, for the desktop app and `maple-agent serve`. +//! +//! Both roles share one data root: the host key and the lock that keeps two +//! servers off one root are this machine's; the paired devices and the +//! pending pairing code belong to the account that is hosting, so a device +//! paired into one account never reaches another account's runtime. The +//! desktop app starts hosting when "Allow remote connections" is on and +//! shows the state here in Settings; the command runs it in the foreground. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +#[cfg(feature = "desktop")] +use std::sync::Mutex; + +use maple_agent::host::LocalHostBackend; +use maple_remote::devices::PairedDevice; +use maple_remote::keys::StaticKey; +use maple_remote::listen::{HostStores, serve_listener}; +use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairing}; +use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; +use maple_remote::wire::HostInfo; +use tokio_util::sync::CancellationToken; + +use super::{account_remote_dir, device_store, pending_pairing_store, remote_dir}; +#[cfg(feature = "desktop")] +use crate::backend::AgentBackend; + +/// What a running host records for `serve pair` to describe. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServeState { + pub listen: String, + pub name: String, + pub host_id: String, +} + +fn state_path(dir: &Path) -> PathBuf { + dir.join("serve.json") +} + +fn lock_path(dir: &Path) -> PathBuf { + dir.join("serve.lock") +} + +fn open_lock(dir: &Path) -> Result { + let path = lock_path(dir); + std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|error| format!("cannot open {}: {error}", path.display())) +} + +/// Take the hosting lock for `dir`. One server per data root: two would +/// race the runtime's own account state and the pairing file. The lock is +/// held for as long as the returned file is open. +fn take_lock(dir: &Path) -> Result { + let lock = open_lock(dir)?; + match lock.try_lock() { + Ok(()) => Ok(lock), + Err(std::fs::TryLockError::WouldBlock) => Err( + "another Maple host already runs on this machine (`maple-agent serve` or another window)" + .to_string(), + ), + Err(std::fs::TryLockError::Error(error)) => Err(format!( + "cannot lock {}: {error}", + lock_path(dir).display() + )), + } +} + +/// Whether a host holds the lock for `dir` right now. The probe takes the +/// lock and lets it go again, so it answers the same on every platform +/// and is never fooled by a crashed host's leftovers or a reused pid. +pub fn lock_is_held(dir: &Path) -> bool { + let Ok(probe) = open_lock(dir) else { + return false; + }; + match probe.try_lock() { + Ok(()) => false, + Err(std::fs::TryLockError::WouldBlock) => true, + Err(std::fs::TryLockError::Error(error)) => { + log::debug!("cannot probe the hosting lock: {error}"); + false + } + } +} + +/// The running host's state, or `None` when no host holds the lock (a +/// crash leaves the state file behind; the lock it does not). +pub fn read_state(dir: &Path) -> Option { + if !lock_is_held(dir) { + return None; + } + let bytes = std::fs::read(state_path(dir)).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Publish a fresh pairing code for a host of `user_id` to accept. +pub fn publish_pairing_code(user_id: &str) -> Result<(PairingCode, PendingPairing), String> { + let dir = account_remote_dir(user_id)?; + let code = PairingCode::generate(); + let pending = pending_pairing_store(&dir).publish(&code)?; + Ok((code, pending)) +} + +/// The code a host of `user_id` accepts right now, if one is pending and +/// not yet expired or consumed. +#[cfg(feature = "desktop")] +pub fn pending_pairing_code(user_id: &str) -> Result, String> { + Ok(pending_pairing_store(&account_remote_dir(user_id)?).current()) +} + +/// The devices paired into `user_id` on this machine. +pub fn list_devices(user_id: &str) -> Result, String> { + device_store(&account_remote_dir(user_id)?).list() +} + +/// Forget a device paired into `user_id`, by public key or by name. +pub fn revoke_device(user_id: &str, device: &str) -> Result { + device_store(&account_remote_dir(user_id)?).revoke(device) +} + +/// A running host: its listener task and the lock on the data root. +/// Dropping it stops nothing; call [`Hosting::stop`]. +pub struct Hosting { + pub listen: SocketAddr, + pub host_id: String, + pub name: String, + shutdown: CancellationToken, + listener: tokio::task::JoinHandle>, + dir: PathBuf, + lock: std::fs::File, +} + +impl Hosting { + /// Bind `listen` and serve the local host of `user_id` until + /// [`Self::stop`]. Only devices paired into `user_id` are admitted. + /// Fails when another host holds the data root or the address cannot + /// be bound. Runs on the backend runtime, whose context spawns the + /// listener task. + pub async fn start( + host: Arc, + user_id: &str, + listen: &str, + name: String, + ) -> Result { + let dir = remote_dir()?; + let lock = take_lock(&dir)?; + let key = StaticKey::load_or_create(&dir.join("host_key.json"))?; + let account_dir = account_remote_dir(user_id)?; + let devices = Arc::new(device_store(&account_dir)); + let pending = Arc::new(pending_pairing_store(&account_dir)); + let hook_devices = Arc::clone(&devices); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |hello| { + if let Err(error) = hook_devices.touch( + &hello.device.public_key, + &hello.device.name, + hello.device.user_id.as_deref(), + ) { + log::warn!("cannot record the device: {error}"); + } + })), + ..Default::default() + }; + let identity = HostIdentity { + app_version: crate::env::APP_VERSION.to_string(), + build: crate::env::build_hash().map(str::to_string), + pcr_environment: format!( + "{:?}", + maple_agent::open_secret_config::configured_pcr0_environment()? + ), + }; + let host_id = key.public_id(); + let info = HostInfo { + id: host_id.clone(), + name: name.clone(), + user_id: Some(user_id.to_string()), + }; + // The saved harness reaches the runtime when it starts + // (`LocalHostBackend::start_runtime`); nothing to apply here. + let server = HostServer::new(host, info, identity, config); + let listener = tokio::net::TcpListener::bind(listen) + .await + .map_err(|error| format!("cannot listen on {listen}: {error}"))?; + let local = listener.local_addr().map_err(|error| error.to_string())?; + maple_agent::private_file::write_private_json( + &state_path(&dir), + &ServeState { + listen: local.to_string(), + name: name.clone(), + host_id: host_id.clone(), + }, + ) + .map_err(|error| format!("cannot write the serve state: {error}"))?; + let shutdown = CancellationToken::new(); + let stores = Arc::new(HostStores { + key, + devices, + pending_pairing: pending, + limiter: PairingLimiter::default(), + }); + let listener = tokio::spawn(serve_listener(listener, server, stores, shutdown.clone())); + Ok(Self { + listen: local, + host_id, + name, + shutdown, + listener, + dir, + lock, + }) + } + + /// Stop serving: end the listener and its connections, then release + /// the lock once the port is free, so a host started next can bind. + /// Resolves when both are gone; callers on the UI thread spawn it on + /// the backend runtime. + pub async fn stop(self) { + self.shutdown.cancel(); + match self.listener.await { + Ok(Ok(())) => log::info!("host {} stopped listening on {}", self.name, self.listen), + Ok(Err(error)) => log::warn!("the host listener ended with an error: {error}"), + Err(error) => log::warn!("the host listener task failed: {error}"), + } + let _ = std::fs::remove_file(state_path(&self.dir)); + // Last, after the listener let the port go. + drop(self.lock); + } +} + +/// Where the desktop app's hosting stands. +#[cfg(feature = "desktop")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostingStatus { + Off, + /// A start is in flight on the backend runtime. + Starting, + Listening { + listen: String, + host_id: String, + name: String, + }, + Failed(String), +} + +#[cfg(feature = "desktop")] +enum HostingState { + Off, + /// A start is in flight; the generation tells it whether it still + /// owns the outcome when it finishes. + Starting(u64), + Listening(Hosting), + Failed(String), +} + +/// The desktop app's host role: starts and stops hosting for the signed-in +/// account and answers Settings. Starting and stopping run on the backend +/// runtime; the state answers at once. +#[cfg(feature = "desktop")] +pub struct HostingController { + backend: Arc, + host: Arc, + user_id: String, + state: Mutex, + generation: std::sync::atomic::AtomicU64, +} + +#[cfg(feature = "desktop")] +impl HostingController { + pub fn new(backend: Arc, host: Arc, user_id: String) -> Self { + Self { + backend, + host, + user_id, + state: Mutex::new(HostingState::Off), + generation: std::sync::atomic::AtomicU64::new(0), + } + } + + fn lock_state(&self) -> std::sync::MutexGuard<'_, HostingState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn status(&self) -> HostingStatus { + match &*self.lock_state() { + HostingState::Off => HostingStatus::Off, + HostingState::Starting(_) => HostingStatus::Starting, + HostingState::Listening(hosting) => HostingStatus::Listening { + listen: hosting.listen.to_string(), + host_id: hosting.host_id.clone(), + name: hosting.name.clone(), + }, + HostingState::Failed(error) => HostingStatus::Failed(error.clone()), + } + } + + /// Start hosting on `listen`. The state reads `Starting` at once; the + /// returned future does the work and resolves to the outcome, so run + /// it on the backend runtime. Already listening or starting resolves + /// to the current status without a second start. + pub fn start( + self: &Arc, + listen: &str, + ) -> impl std::future::Future + Send + 'static { + let this = Arc::clone(self); + let listen = listen.to_string(); + let generation = { + let mut state = self.lock_state(); + match &*state { + HostingState::Off | HostingState::Failed(_) => { + let generation = self + .generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + *state = HostingState::Starting(generation); + Some(generation) + } + HostingState::Starting(_) | HostingState::Listening(_) => None, + } + }; + async move { + let Some(generation) = generation else { + return this.status(); + }; + let result = Hosting::start( + Arc::clone(&this.host), + &this.user_id, + &listen, + crate::env::hostname(), + ) + .await; + let stale = { + let mut state = this.lock_state(); + if matches!(*state, HostingState::Starting(current) if current == generation) { + *state = match result { + Ok(hosting) => { + log::info!( + "hosting as {} ({}) on {}", + hosting.name, + hosting.host_id, + hosting.listen + ); + HostingState::Listening(hosting) + } + Err(error) => { + log::warn!("hosting did not start: {error}"); + HostingState::Failed(error) + } + }; + None + } else { + // A stop arrived while the start ran: the stop wins. + result.ok() + } + }; + if let Some(hosting) = stale { + hosting.stop().await; + } + this.status() + } + } + + /// Stop hosting. The state reads `Off` at once; the listener and the + /// lock go on the backend runtime. + pub fn stop(&self) { + let previous = std::mem::replace(&mut *self.lock_state(), HostingState::Off); + if let HostingState::Listening(hosting) = previous { + self.backend.spawn(hosting.stop()); + } + } +} + +#[cfg(feature = "desktop")] +impl Drop for HostingController { + fn drop(&mut self) { + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_lock_probe_sees_a_held_lock_and_a_free_one() { + let dir = std::env::temp_dir().join(format!("maple-hosting-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(!lock_is_held(&dir), "no host runs on a fresh directory"); + assert!(read_state(&dir).is_none()); + let held = take_lock(&dir).unwrap(); + assert!(lock_is_held(&dir)); + assert!(take_lock(&dir).is_err(), "a second host is refused"); + maple_agent::private_file::write_private_json( + &state_path(&dir), + &ServeState { + listen: "127.0.0.1:7130".to_string(), + name: "box".to_string(), + host_id: "h".to_string(), + }, + ) + .unwrap(); + assert_eq!(read_state(&dir).unwrap().name, "box"); + drop(held); + assert!(!lock_is_held(&dir), "the probe leaves the lock free"); + assert!( + read_state(&dir).is_none(), + "a state file without its lock is a crashed host's leftover" + ); + assert!(take_lock(&dir).is_ok()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/app/src/remote/mod.rs b/apps/maple-agent/app/src/remote/mod.rs new file mode 100644 index 000000000..9944818cb --- /dev/null +++ b/apps/maple-agent/app/src/remote/mod.rs @@ -0,0 +1,103 @@ +//! Remote development: this machine as a host ([`host`]) and as a client +//! of other hosts ([`client`]), plus the files both roles keep under +//! `/remote/`. +//! +//! The machine owns its two static keys and the hosting lock; what belongs +//! to one account (the devices paired into it, the code it accepts, the +//! hosts it saved) lives under that account's scope, so accounts on one +//! machine never see each other's peers. + +#[cfg(feature = "desktop")] +pub mod client; +#[cfg(feature = "serve")] +pub mod host; + +#[cfg(feature = "serve")] +use std::path::{Path, PathBuf}; + +#[cfg(feature = "serve")] +use maple_remote::devices::DeviceStore; +#[cfg(feature = "serve")] +use maple_remote::pairing::PendingPairingStore; + +/// Default listen address for a host. Not 8080, which the proxy mode uses. +/// Read by the `serve` command line in every build, so it lives outside +/// the feature gate. +pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; + +/// Where this machine keeps its keys and the hosting lock. Created +/// owner-only on first use. +#[cfg(feature = "serve")] +pub fn remote_dir() -> Result { + private_dir(crate::backend::local_data_root().join("remote")) +} + +/// Where the host keeps what belongs to one account: the devices paired +/// into it and the pairing code it currently accepts. Created owner-only +/// on first use. +#[cfg(feature = "serve")] +pub fn account_remote_dir(user_id: &str) -> Result { + private_dir(account_remote_dir_under(&remote_dir()?, user_id)?) +} + +/// Pure path arithmetic behind [`account_remote_dir`]: the account's +/// directory under the machine's remote directory. +#[cfg(feature = "serve")] +fn account_remote_dir_under(remote_dir: &Path, user_id: &str) -> Result { + let scope = maple_agent::maple_api::account_scope(user_id)?; + Ok(remote_dir.join("accounts").join(scope)) +} + +#[cfg(feature = "serve")] +fn private_dir(dir: PathBuf) -> Result { + std::fs::create_dir_all(&dir) + .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(dir) +} + +/// The devices paired into the account whose directory is `account_dir`. +#[cfg(feature = "serve")] +pub fn device_store(account_dir: &Path) -> DeviceStore { + DeviceStore::new(account_dir.join("devices.json")) +} + +/// The pairing code the account whose directory is `account_dir` accepts. +#[cfg(feature = "serve")] +pub fn pending_pairing_store(account_dir: &Path) -> PendingPairingStore { + PendingPairingStore::new(account_dir.join("pending_pairing.json")) +} + +#[cfg(all(test, feature = "serve"))] +mod tests { + use super::*; + + #[test] + fn devices_and_codes_live_under_the_account() { + let root = Path::new("/data/remote"); + let a = account_remote_dir_under(root, "user-a").unwrap(); + let b = account_remote_dir_under(root, "user-b").unwrap(); + assert_ne!(a, b, "two accounts never share a device list"); + assert_eq!( + a, + account_remote_dir_under(root, " USER-A ").unwrap(), + "the scope follows the normalized account id" + ); + assert_eq!(a.parent().unwrap().parent().unwrap(), root); + assert_eq!(a.parent().unwrap().file_name().unwrap(), "accounts"); + assert_eq!( + device_store(&a).path(), + a.join("devices.json"), + "the device file sits in the account directory" + ); + assert_eq!( + pending_pairing_store(&a).path(), + a.join("pending_pairing.json") + ); + assert!(account_remote_dir_under(root, " ").is_err()); + } +} diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs new file mode 100644 index 000000000..ab77edcef --- /dev/null +++ b/apps/maple-agent/app/src/serve.rs @@ -0,0 +1,284 @@ +//! `maple-agent serve`: publish this machine's agent runtime to paired +//! clients over the LAN or a Tailscale network. +//! +//! The host signs in on its own (`maple-agent login`) and holds its own +//! credentials; clients bring nothing but their device key. A one-time code +//! from `serve pair` admits a device into the saved account; `serve devices` +//! lists and revokes that account's devices. One server per data root, +//! enforced with a lock file. + +use clap::{Args, Subcommand}; + +use crate::remote::DEFAULT_LISTEN; + +#[derive(Debug, Clone, PartialEq, Eq, Args)] +pub struct ServeArgs { + #[command(subcommand)] + pub command: Option, + /// Address to listen on. Pairing is the gate, so every interface is + /// the default; give one address (a Tailscale IP) to narrow it. + #[arg(long, env = "MAPLE_SERVE_LISTEN", default_value = DEFAULT_LISTEN)] + pub listen: String, + /// Name clients show for this host. Defaults to the machine's hostname. + #[arg(long, env = "MAPLE_SERVE_NAME")] + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ServeCommand { + /// Publish a one-time pairing code for a new device. The running host + /// accepts it for five minutes. + Pair, + /// Devices paired with this host. + Devices { + #[command(subcommand)] + command: DevicesCommand, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum DevicesCommand { + List, + /// Forget a device by public key or by name. Its live connections end + /// within seconds. + Revoke { + device: String, + }, +} + +#[cfg(feature = "serve")] +pub use enabled::run; + +#[cfg(feature = "serve")] +mod enabled { + #[cfg(unix)] + use std::os::unix::ffi::OsStringExt as _; + use std::sync::Arc; + + use super::{DevicesCommand, ServeArgs, ServeCommand}; + use crate::backend::{AgentBackend, RestoreOutcome}; + use crate::remote::host::{self, Hosting}; + + pub fn run(args: ServeArgs) -> Result<(), String> { + let ServeArgs { + command, + listen, + name, + } = args; + match command { + None => run_server(&listen, name), + Some(ServeCommand::Pair) => publish_code(&saved_account()?), + Some(ServeCommand::Devices { + command: DevicesCommand::List, + }) => list_devices(&saved_account()?), + Some(ServeCommand::Devices { + command: DevicesCommand::Revoke { device }, + }) => revoke_device(&saved_account()?, &device), + } + } + + /// Tell systemd how the service is doing, when it asked + /// (`NOTIFY_SOCKET` set). Silent everywhere else. Only the main process + /// may report, so this is the command's alone. + fn sd_notify(state: &str) { + #[cfg(unix)] + { + let Some(socket) = std::env::var_os("NOTIFY_SOCKET") else { + return; + }; + let mut path = socket.into_encoded_bytes(); + // An abstract socket is written with a leading `@`; the + // address needs a NUL there. + if path.first() == Some(&b'@') { + path[0] = 0; + } + let Ok(socket) = std::os::unix::net::UnixDatagram::unbound() else { + return; + }; + let sent = if path.first() == Some(&0) { + #[cfg(target_os = "linux")] + { + use std::os::linux::net::SocketAddrExt as _; + std::os::unix::net::SocketAddr::from_abstract_name(&path[1..]) + .and_then(|address| socket.send_to_addr(state.as_bytes(), &address)) + } + #[cfg(not(target_os = "linux"))] + { + Err(std::io::Error::other("abstract sockets are Linux only")) + } + } else { + socket.send_to( + state.as_bytes(), + std::path::PathBuf::from(std::ffi::OsString::from_vec(path)), + ) + }; + if let Err(error) = sent { + log::debug!("sd_notify failed: {error}"); + } + } + #[cfg(not(unix))] + let _ = state; + } + + const NO_SIGN_IN: &str = + "No saved Maple sign-in on this machine. Run `maple-agent login` first."; + + /// The account the device commands act on: the one a host here would + /// serve. Read from the saved sign-in without contacting the server. + fn saved_account() -> Result { + AgentBackend::new(crate::configured_api_url())? + .saved_user_id() + .ok_or_else(|| NO_SIGN_IN.to_string()) + } + + fn run_server(listen: &str, name: Option) -> Result<(), String> { + let backend = Arc::new(AgentBackend::new(crate::configured_api_url())?); + let saved = backend + .saved_user_id() + .ok_or_else(|| NO_SIGN_IN.to_string())?; + let user_id = match backend.restore_outcome_now() { + RestoreOutcome::Valid(user_id) => user_id, + RestoreOutcome::Rejected => { + return Err( + "The saved Maple sign-in was rejected. Run `maple-agent login` again." + .to_string(), + ); + } + RestoreOutcome::Unavailable => { + // A host that boots before the network (a systemd unit at + // login, a laptop off Wi-Fi) still serves: the sign-in is + // kept, requests report the server state, and the sign-in + // is retried behind them until it goes through. + log::warn!("the Maple server could not be reached; serving with the saved sign-in"); + eprintln!( + "The Maple server could not be reached. Serving anyway; requests fail \ + until the sign-in goes through, which is retried in the background." + ); + retry_sign_in(&backend); + saved + } + }; + crate::adopt_legacy_session_defaults(&backend, &user_id); + let local_host = backend.local_host(&user_id); + let name = name.unwrap_or_else(crate::env::hostname); + let runtime = backend.runtime_handle(); + let hosting = runtime.block_on(Hosting::start(local_host, &user_id, listen, name))?; + eprintln!( + "Serving as host \"{}\" ({}) on {}.", + hosting.name, hosting.host_id, hosting.listen + ); + eprintln!("Pair a device with `maple-agent serve pair`. Stop with Ctrl-C."); + // Under systemd (`Type=notify`) the unit is up once the port is + // bound, not when the process forked. + sd_notify(&format!( + "READY=1\nSTATUS=Serving as {} on {}", + hosting.name, hosting.listen + )); + runtime.block_on(async { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate() => {} + } + }); + sd_notify("STOPPING=1"); + // Wait for the port and the lock to go: a restart right after + // (systemd's `Restart=`) must be able to bind. + runtime.block_on(hosting.stop()); + Ok(()) + } + + /// Validate the saved sign-in again, with growing pauses, until the + /// server answers. Requests that need the session wait for each + /// attempt (`AgentBackend::restore_in_background`) and fail between + /// them. A rejection ends the retries; the host stays up so the + /// operator sees the error on the next request and in the log. + fn retry_sign_in(backend: &Arc) { + let runtime = backend.runtime_handle(); + let backend = Arc::clone(backend); + runtime.spawn(async move { + let mut pause = std::time::Duration::from_secs(5); + loop { + tokio::time::sleep(pause).await; + match backend.restore_in_background().await { + Ok(RestoreOutcome::Valid(_)) => { + log::info!("the saved sign-in went through"); + return; + } + Ok(RestoreOutcome::Rejected) => { + log::error!("the saved sign-in was rejected; run `maple-agent login`"); + return; + } + Ok(RestoreOutcome::Unavailable) | Err(_) => { + pause = (pause * 2).min(std::time::Duration::from_secs(5 * 60)); + } + } + } + }); + } + + /// Resolves on SIGTERM, which `systemctl stop` sends. Never resolves + /// where there is no such signal. + async fn terminate() { + #[cfg(unix)] + { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(error) => { + log::warn!("cannot listen for SIGTERM: {error}"); + std::future::pending::<()>().await; + } + } + } + #[cfg(not(unix))] + std::future::pending::<()>().await; + } + + fn publish_code(user_id: &str) -> Result<(), String> { + let (code, _pending) = host::publish_pairing_code(user_id)?; + // The code goes to stdout so it can be piped; guidance to stderr. + println!("{}", code.display()); + eprintln!( + "Pairing code published. It admits one device and expires in {} minutes.", + maple_remote::pairing::CODE_TTL.as_secs() / 60 + ); + match host::read_state(&crate::remote::remote_dir()?) { + Some(state) => eprintln!( + "In the Maple app, add host \"{}\" at {} and enter the code.", + state.name, state.listen + ), + None => eprintln!( + "No host is running here. Start `maple-agent serve` or turn on remote \ + connections in the app before the code expires." + ), + } + Ok(()) + } + + fn list_devices(user_id: &str) -> Result<(), String> { + let devices = host::list_devices(user_id)?; + if devices.is_empty() { + eprintln!("No paired devices. Publish a code with `maple-agent serve pair`."); + return Ok(()); + } + for device in devices { + println!( + "{}\t{}\t{}", + device.public_key, + device.name, + device.user_id.as_deref().unwrap_or("-") + ); + } + Ok(()) + } + + fn revoke_device(user_id: &str, device: &str) -> Result<(), String> { + let removed = host::revoke_device(user_id, device)?; + eprintln!( + "Revoked {} ({}). A live connection from it ends within seconds.", + removed.name, removed.public_key + ); + Ok(()) + } +} diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 92393cd31..809fbd3e8 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -1,5 +1,10 @@ -//! App settings persisted to ~/.config/maple-agent/settings.json and local -//! usage aggregation read from the goose usage ledger. +//! App settings persisted to ~/.config/maple-agent/settings.json. +//! +//! These are client-side: how this window looks and behaves. Defaults a +//! host applies to new tasks (permission mode, web access, harness +//! instructions) live in the host's account config and are edited through +//! `HostBackend::session_defaults`. State about a host's tasks and +//! projects is kept here per host, keyed by host id. // This module is the desktop frontend's boundary. A headless build (no // `desktop` feature) uses only a few entry points, so the rest is unused @@ -8,17 +13,30 @@ use std::path::PathBuf; +/// Client-side state about one host's tasks and projects. Task ids and +/// project paths only mean something on the host they came from, so two +/// hosts never share an entry. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HostUiState { + /// Sidebar task ids the user pinned, in pin order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pinned_tasks: Vec, + /// Display names for project roots, keyed by absolute path on the host. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub project_names: std::collections::BTreeMap, +} + +impl HostUiState { + fn is_empty(&self) -> bool { + self.pinned_tasks.is_empty() && self.project_names.is_empty() + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppSettings { - /// Default permission policy for new sessions; see [`PermissionMode`]. - #[serde(default)] - pub default_permission_mode: PermissionMode, /// Whether tool cards show input/output payloads by default. #[serde(default = "default_tool_details")] pub tool_details: bool, - /// Whether new tasks can use the web tools. - #[serde(default = "default_web_enabled")] - pub default_web_enabled: bool, /// Whether completed tool calls get a one-line model summary. #[serde(default = "default_tool_summaries")] pub tool_summaries: bool, @@ -33,14 +51,19 @@ pub struct AppSettings { /// disables that exact slot. Missing entries retain their shipped key. #[serde(default)] pub shortcut_overrides: std::collections::BTreeMap>, + /// Per-host task and project state, keyed by host id. The local host + /// is [`maple_agent::host::HostId::LOCAL`]. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub hosts: std::collections::BTreeMap, + /// The host the last new task was created on, by host id; absent for + /// the local host. The next launch targets it again once it connects, + /// since most people work on one host at a time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_task_host: Option, + /// Whether this window also serves the account's runtime to paired + /// devices. Off until asked: a fresh install never listens. #[serde(default)] - pub pinned_roots: Vec, - /// Sidebar task ids the user pinned, in pin order. - #[serde(default)] - pub pinned_tasks: Vec, - /// Display names for project roots, keyed by absolute path. - #[serde(default)] - pub project_names: std::collections::HashMap, + pub allow_remote_connections: bool, /// Whether run completion, permissions, and questions raise desktop /// notifications while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -49,10 +72,6 @@ pub struct AppSettings { /// this, so it is a Maple setting. #[serde(default)] pub reduce_motion: bool, - /// Opening system prompt text for agents this app hosts. Empty means - /// [`DEFAULT_HARNESS_INSTRUCTIONS`]. - #[serde(default)] - pub harness_instructions: String, /// Window size and state from the last run. #[serde(default)] pub window: Option, @@ -72,6 +91,31 @@ pub struct AppSettings { /// Text-to-speech speed multiplier; see [`TTS_SPEEDS`]. #[serde(default = "default_tts_speed")] pub tts_speed: f32, + + /// Fields older versions wrote at the top level. Read once and never + /// written back: `load_settings` moves the task and project state under + /// the local host, and the local host adopts the session defaults into + /// its account config (see [`Self::legacy_session_defaults`]). The next + /// save drops them. + #[doc(hidden)] + #[serde(flatten, default, skip_serializing)] + pub legacy: LegacyTopLevelSettings, +} + +/// See [`AppSettings::legacy`]. +#[doc(hidden)] +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct LegacyTopLevelSettings { + #[serde(default, rename = "default_permission_mode")] + pub permission_mode: Option, + #[serde(default, rename = "default_web_enabled")] + pub web_enabled: Option, + #[serde(default)] + pub harness_instructions: Option, + #[serde(default)] + pub pinned_tasks: Vec, + #[serde(default)] + pub project_names: std::collections::BTreeMap, } /// Voxtral voice ids with their labels, in the order the settings menu @@ -224,21 +268,48 @@ impl WindowState { } } -/// Opening system prompt for agents this app hosts: the agent is Maple. -/// The runtime appends its tool and runtime guidance after this text. -pub const DEFAULT_HARNESS_INSTRUCTIONS: &str = - "You are a general-purpose AI agent called Maple, created by Maple AI. -You run in the Maple app's Agent Mode; users know you simply as Maple."; +/// Opening system prompt for agents a host runs. Kept with the host +/// vocabulary; re-exported here for the settings screen, which a headless +/// build does not have. +#[cfg_attr(not(feature = "desktop"), allow(unused_imports))] +pub use maple_agent::host::DEFAULT_HARNESS_INSTRUCTIONS; impl AppSettings { - /// The harness instructions to hand the runtime: the saved text, or the - /// default when nothing is saved. - pub fn effective_harness_instructions(&self) -> String { - let saved = self.harness_instructions.trim(); - if saved.is_empty() { - DEFAULT_HARNESS_INSTRUCTIONS.to_string() - } else { - saved.to_string() + /// Client-side state for one host, empty when none is saved. + #[cfg(test)] + pub fn host_state(&self, host: &maple_agent::host::HostId) -> HostUiState { + self.hosts.get(host.as_str()).cloned().unwrap_or_default() + } + + /// Mutable client-side state for one host, created on first use. + pub fn host_state_mut(&mut self, host: &maple_agent::host::HostId) -> &mut HostUiState { + self.hosts.entry(host.as_str().to_string()).or_default() + } + + /// Session defaults an older version saved here. The local host adopts + /// them into its account config once; see + /// [`maple_agent::host::LocalHostBackend::migrate_session_defaults`]. + pub fn legacy_session_defaults(&self) -> maple_agent::host::LegacySessionDefaults { + maple_agent::host::LegacySessionDefaults { + permission_mode: self.legacy.permission_mode.clone(), + web_enabled: self.legacy.web_enabled, + harness_instructions: self.legacy.harness_instructions.clone(), + } + } + + /// Move task and project state an older version kept at the top level + /// under the local host. Values already under the local host win. + fn adopt_legacy_host_state(&mut self) { + let legacy = HostUiState { + pinned_tasks: std::mem::take(&mut self.legacy.pinned_tasks), + project_names: std::mem::take(&mut self.legacy.project_names), + }; + if legacy.is_empty() { + return; + } + let local = self.host_state_mut(&maple_agent::host::HostId::local()); + if local.is_empty() { + *local = legacy; } } } @@ -255,10 +326,6 @@ fn default_chat_font_size() -> u8 { 14 } -fn default_web_enabled() -> bool { - true -} - fn default_tool_details() -> bool { false } @@ -274,25 +341,23 @@ fn default_tool_summaries() -> bool { impl Default for AppSettings { fn default() -> Self { Self { - default_permission_mode: PermissionMode::default(), tool_details: default_tool_details(), - default_web_enabled: default_web_enabled(), tool_summaries: default_tool_summaries(), composer_vim_enabled: false, application_vim_enabled: false, shortcut_overrides: std::collections::BTreeMap::new(), - pinned_roots: Vec::new(), - pinned_tasks: Vec::new(), - project_names: std::collections::HashMap::new(), + hosts: std::collections::BTreeMap::new(), + last_task_host: None, + allow_remote_connections: false, desktop_notifications: default_desktop_notifications(), reduce_motion: false, - harness_instructions: String::new(), window: None, theme: default_theme(), chat_font_family: default_chat_font_family(), chat_font_size: default_chat_font_size(), tts_voice: default_tts_voice(), tts_speed: default_tts_speed(), + legacy: LegacyTopLevelSettings::default(), } } } @@ -342,13 +407,15 @@ pub fn load_settings() -> AppSettings { return AppSettings::default(); } }; - serde_json::from_str(&text).unwrap_or_else(|error| { + let mut settings: AppSettings = serde_json::from_str(&text).unwrap_or_else(|error| { log::warn!( "Settings at {} are not valid; using defaults: {error}", path.display() ); AppSettings::default() - }) + }); + settings.adopt_legacy_host_state(); + settings } /// Serializes tests that swap `XDG_CONFIG_HOME` process-wide: while a swap @@ -438,159 +505,10 @@ pub fn update_settings_and_wait(update: impl FnOnce(&mut AppSettings) + Send + ' let _ = rx.recv(); } -/// One aggregated usage row: per session or per model. -#[derive(Debug, Clone, Default)] -pub struct UsageRow { - pub label: String, - pub sessions: u64, - pub turns: u64, - pub total_tokens: i64, - pub cost: f64, -} - -#[derive(Debug, Clone, Default)] -pub struct UsageSummary { - pub totals: UsageRow, - pub by_model: Vec, - pub by_session: Vec, -} - -/// Read usage totals from the goose usage ledger for one account scope. -pub fn load_usage(account_scope: &str) -> UsageSummary { - let db = crate::backend::account_session_db(account_scope); - let Some(conn) = crate::backend::open_session_db_read_only(&db) else { - return UsageSummary::default(); - }; - usage_from_ledger(&conn) -} - -/// Aggregate one account's ledger. -/// -/// A subagent has a session of its own, and its provider calls land in -/// the ledger under it. Every row counts against the task that delegated -/// the work, so the reader sees what a task cost in total. Goose refuses -/// a subagent of a subagent, so resolving one parent is enough. -fn usage_from_ledger(conn: &rusqlite::Connection) -> UsageSummary { - let mut summary = UsageSummary::default(); - - if let Ok(mut stmt) = conn.prepare( - "SELECT COUNT(*), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cost),0) \ - FROM usage_ledger", - ) && let Ok(row) = stmt.query_row([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, f64>(2)?, - )) - }) { - summary.totals = UsageRow { - label: "All activity".to_string(), - sessions: 0, - turns: row.0.max(0) as u64, - total_tokens: row.1, - cost: row.2, - }; - } - - if let Ok(mut stmt) = conn.prepare( - "SELECT u.model, COUNT(DISTINCT COALESCE(s.parent_session_id, u.session_id)), COUNT(*), \ - COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ - FROM usage_ledger u LEFT JOIN sessions s ON s.id = u.session_id \ - GROUP BY u.model ORDER BY SUM(u.total_tokens) DESC", - ) && let Ok(rows) = stmt.query_map([], |row| { - Ok(UsageRow { - label: row - .get::<_, Option>(0)? - .unwrap_or_else(|| "unknown".into()), - sessions: row.get::<_, i64>(1)?.max(0) as u64, - turns: row.get::<_, i64>(2)?.max(0) as u64, - total_tokens: row.get::<_, i64>(3)?, - cost: row.get::<_, f64>(4)?, - }) - }) { - for row in rows.flatten() { - summary.totals.sessions += row.sessions; - summary.by_model.push(row); - } - } - - if let Ok(mut stmt) = conn.prepare( - "SELECT COALESCE(parent.name, s.name), COALESCE(s.parent_session_id, u.session_id) AS task, \ - COUNT(*), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ - FROM usage_ledger u JOIN sessions s ON s.id = u.session_id \ - LEFT JOIN sessions parent ON parent.id = s.parent_session_id \ - GROUP BY task ORDER BY MAX(u.created_timestamp) DESC LIMIT 20", - ) && let Ok(rows) = stmt.query_map([], |row| { - Ok(UsageRow { - label: { - let name: String = row.get::<_, Option>(0)?.unwrap_or_default(); - let id: String = row.get(1)?; - if name.trim().is_empty() { id } else { name } - }, - sessions: 1, - turns: row.get::<_, i64>(2)?.max(0) as u64, - total_tokens: row.get::<_, i64>(3)?, - cost: row.get::<_, f64>(4)?, - }) - }) { - for row in rows.flatten() { - summary.by_session.push(row); - } - } - - summary -} - #[cfg(test)] mod tests { use super::*; - /// A subagent bills to the task that delegated the work, so the - /// usage screen shows one row per task and not one per subagent. - #[test] - fn subagent_usage_counts_against_its_parent_task() { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - conn.execute_batch( - "CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - parent_session_id TEXT - ); - CREATE TABLE usage_ledger ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - created_timestamp INTEGER NOT NULL, - model TEXT, - total_tokens INTEGER, - cost REAL - ); - INSERT INTO sessions VALUES ('task-1', 'Review the parser', NULL); - INSERT INTO sessions VALUES ('sub-1', 'Delegated task', 'task-1'); - INSERT INTO sessions VALUES ('task-2', 'Other work', NULL); - INSERT INTO usage_ledger (session_id, created_timestamp, model, total_tokens, cost) - VALUES ('task-1', 10, 'maple-1', 100, 1.0), - ('sub-1', 20, 'maple-1', 400, 4.0), - ('task-2', 30, 'maple-1', 700, 7.0);", - ) - .unwrap(); - - let usage = usage_from_ledger(&conn); - let rows = usage - .by_session - .iter() - .map(|row| (row.label.as_str(), row.turns, row.total_tokens)) - .collect::>(); - assert_eq!( - rows, - vec![("Other work", 1, 700), ("Review the parser", 2, 500)], - "the subagent's tokens belong to the task that delegated them" - ); - // Two tasks ran, not three sessions. - assert_eq!(usage.by_model.len(), 1); - assert_eq!(usage.by_model[0].sessions, 2); - assert_eq!(usage.totals.total_tokens, 1200); - } - #[test] fn permission_mode_round_trips_as_a_string() { for mode in [PermissionMode::SmartApprove, PermissionMode::Auto] { @@ -608,10 +526,65 @@ mod tests { assert_eq!(PermissionMode::default(), PermissionMode::SmartApprove); } + /// An older file kept task state and session defaults at the top + /// level. Loading moves the task state under the local host, hands the + /// session defaults to the local host once, and the next save drops + /// the old keys. #[test] - fn default_settings_keep_the_on_disk_permission_string() { - let json = serde_json::to_value(AppSettings::default()).expect("serialize"); - assert_eq!(json["default_permission_mode"], "smart_approve"); + fn legacy_top_level_state_moves_under_the_local_host() { + let mut settings: AppSettings = serde_json::from_str( + r#"{ + "default_permission_mode": "auto", + "default_web_enabled": false, + "harness_instructions": "custom", + "pinned_tasks": ["s1"], + "settled_tasks": ["s2"], + "project_names": {"/p": "Project"} + }"#, + ) + .expect("old file"); + settings.adopt_legacy_host_state(); + let local = settings.host_state(&maple_agent::host::HostId::local()); + assert_eq!(local.pinned_tasks, vec!["s1".to_string()]); + assert_eq!( + local.project_names.get("/p").map(String::as_str), + Some("Project") + ); + let legacy = settings.legacy_session_defaults(); + assert_eq!(legacy.permission_mode.as_deref(), Some("auto")); + assert_eq!(legacy.web_enabled, Some(false)); + assert_eq!(legacy.harness_instructions.as_deref(), Some("custom")); + + let json = serde_json::to_value(&settings).expect("serialize"); + for key in [ + "default_permission_mode", + "default_web_enabled", + "harness_instructions", + "pinned_tasks", + "settled_tasks", + "project_names", + ] { + assert!(json.get(key).is_none(), "{key} must not be written back"); + } + assert_eq!(json["hosts"]["local"]["pinned_tasks"][0], "s1"); + assert!( + settings + .host_state(&maple_agent::host::HostId::new("other")) + .pinned_tasks + .is_empty() + ); + } + + #[test] + fn a_file_with_no_legacy_keys_reports_nothing_to_migrate() { + let settings = AppSettings::default(); + assert!(settings.legacy_session_defaults().is_empty()); + assert!( + serde_json::to_value(&settings) + .unwrap() + .get("hosts") + .is_none() + ); } #[test] diff --git a/apps/maple-agent/app/src/shortcuts.rs b/apps/maple-agent/app/src/shortcuts.rs index 3c15d24eb..d355b438a 100644 --- a/apps/maple-agent/app/src/shortcuts.rs +++ b/apps/maple-agent/app/src/shortcuts.rs @@ -481,7 +481,7 @@ fn context_overlap(left: Option<&str>, right: Option<&str>) -> Option { self.execute_application_vim(command, window, cx) } - ChatCommand::ChooseProject => self.toggle_root_menu(cx), + ChatCommand::ChooseProject => self.toggle_project_picker(cx), ChatCommand::CopySelection => self.copy_selected_text(cx), ChatCommand::Escape if self.application_vim_enabled => { self.application_escape(window, cx) @@ -210,10 +210,10 @@ mod tests { } fn screen(cx: &mut TestAppContext) -> gpui::Entity { - let backend = Arc::new( - AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).expect("backend"), - ); - cx.new(|cx| ChatScreen::new_inner(backend, "user".to_string(), cx)) + let backend = + Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend")); + let host = backend.local_host("user"); + cx.new(|cx| ChatScreen::new_inner(backend, host, "user".to_string(), cx)) } #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 86ac4853e..c91b44b17 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -9,10 +9,11 @@ use maple_agent::agent::{AgentSlashCommand, SideQuestionTurn}; use super::cache::MarkdownKind; use super::commands::ChatCommand; +use super::hosts::status_dot; use super::transcript::{render_plan_row, render_subagent_row}; use super::{ COMPOSER_PLACEHOLDER, ChatPopup, ChatScreen, DraftImage, OpenSettingsSection, - ROOT_MENU_RECENTS, SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, + SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, }; use crate::ui::icons::{icon, spinner}; use crate::ui::markdown; @@ -60,27 +61,84 @@ impl ChatScreen { .text_color(gpui::rgb(theme::text_primary())) .child(title), ) + .when(self.hosts.len() > 1, |row| { + // One host needs no chip. With a task open the header names + // the host that task runs on, as a badge: the task cannot + // move, so there is nothing to switch. On the new-task screen + // the chip picks the host the task will be created on. + match self.selected_host.as_ref() { + Some((owner, name)) => { + let online = self.hosts.get(owner).is_some_and(|entry| entry.online); + row.child( + div() + .id("task-host") + .h_8() + .flex() + .flex_none() + .items_center() + .gap_1() + .px_2() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_secondary())) + .tooltip(widgets::tooltip("This task runs here", None)) + .child(status_dot(online)) + .child(div().whitespace_nowrap().child(name.clone())), + ) + } + None => { + let online = self.target_host_online(); + let (frame, color) = chip_frame( + "host-picker", + self.target_host_label.clone(), + self.popup.is_open(&ChatPopup::Host), + false, + ); + let chip = frame + .tooltip(widgets::tooltip("New tasks run here", None)) + .child(status_dot(online)) + .child( + div() + .whitespace_nowrap() + .child(self.target_host_label.clone()), + ) + .child(icon("chevron-down", px(14.), color)) + // The header is a window drag region; a press here + // is the chip's. + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }); + row.child( + self.with_menu( + ChatPopup::Host, + chip, + Placement::BelowStart, + window, + cx, + |this, _| this.host_menu(), + ) + .flex_none(), + ) + } + } + }) .child( - self.with_menu( - ChatPopup::Project, - chip( - "root-picker", - Some("folder-open"), - self.project_label.clone(), - true, - self.popup.is_open(&ChatPopup::Project), - false, - ) - // The header is a window drag region; a press here is the - // chip's. - .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { - cx.stop_propagation(); - }), - Placement::BelowStart, - window, - cx, - Self::project_menu, + chip( + "root-picker", + Some("folder-open"), + self.project_label.clone(), + true, + self.project_picker.is_some(), + false, ) + // The header is a window drag region; a press here is the + // chip's. + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.execute_command(ChatCommand::ChooseProject, window, cx); + })) .flex_none(), ) .when_some(self.branch_label.clone(), |row, branch| { @@ -116,101 +174,13 @@ impl ChatScreen { .relative() .child( self.popup - .trigger(popup, button, cx, move |this, window, cx| { - this.press_chip(popup, window, cx) + .trigger(popup, button, cx, move |this, _window, cx| { + this.toggle_popup(popup, cx) }), ) .children(menu) } - /// A press on a chip: the header's project chip runs the command it - /// shares with its shortcut; a composer chip opens or closes its menu. - fn press_chip(&mut self, popup: ChatPopup, window: &mut Window, cx: &mut Context) { - match popup { - ChatPopup::Project => self.execute_command(ChatCommand::ChooseProject, window, cx), - popup => self.toggle_popup(popup, cx), - } - } - - /// The header chip's menu: recent projects, then "New project…", then - /// manual entry when the native folder picker is unavailable. - fn project_menu(&self, cx: &mut Context) -> Menu { - let mut menu = Menu::new("project-menu", px(480.)) - .label("Projects") - .application_vim(self.application_vim_enabled); - for path in self.recent_roots.iter().take(ROOT_MENU_RECENTS) { - let pick = path.clone(); - menu = menu.item( - MenuItem::new( - SharedString::from(format!("root-{path}")), - path.clone(), - move |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.select_project_root(pick.clone(), cx); - }, - ) - .truncate_start() - .current(self.project_root.as_deref() == Some(path.as_str())), - ); - } - menu = menu.item( - MenuItem::new( - "root-choose", - "New project…", - |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.choose_root_dialog(cx); - }, - ) - .icon("folder-plus"), - ); - let Some(input) = self.root_input.clone() else { - return menu; - }; - menu.child( - div() - .px_3() - .pt_1() - .pb_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child("Or type an absolute path:"), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .px_3() - .pb_2() - .child( - div() - .flex_1() - .debug_selector(|| "root-path-field".to_string()) - .child(input), - ) - .child( - div() - .id("root-apply") - .px_3() - .py_1() - .rounded(theme::RADIUS_SM) - .bg(gpui::rgb(theme::accent())) - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::on_accent())) - .hover(|style| style.bg(gpui::rgb(theme::accent_hover())).cursor_pointer()) - .active(|style| style.bg(gpui::rgb(theme::send_bottom()))) - .on_click(cx.listener(|this, _event, _window, cx| { - if let Some(path) = - this.root_input.as_ref().map(|input| input.read(cx).text()) - { - this.select_project_root(path, cx); - } - })) - .child("Go"), - ), - ) - } - fn model_menu(&self) -> Menu { let selected = self.selected_model.as_deref(); Menu::new("model-menu", px(320.)) @@ -461,15 +431,13 @@ impl ChatScreen { input.set_placeholder(SIDE_THREAD_PLACEHOLDER, cx) }); } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let question = question.to_string(); let callback_id = request_id.clone(); self.call( async move { - backend - .ask_side_question(&user_id, &session_id, request_id, prior, question) + host.ask_side_question(session_id.clone(), request_id, prior, question) .await }, cx, @@ -1192,6 +1160,8 @@ pub(super) fn slash_entries_for(token: &str, skills: &[AgentSlashCommand]) -> Ve .collect() } +impl ChatScreen {} + /// One control in the composer chip row. `active` means its menu is /// open; `highlight` means the feature it toggles is on, shown in the /// accent so the two states never look alike. @@ -1203,6 +1173,22 @@ fn chip( active: bool, highlight: bool, ) -> gpui::Stateful
{ + let label = label.into(); + let (frame, color) = chip_frame(id, label.clone(), active, highlight); + frame + .children(leading.map(|name| icon(name, px(16.), color))) + .child(div().whitespace_nowrap().child(label)) + .when(chevron, |el| el.child(icon("chevron-down", px(14.), color))) +} + +/// A header chip with no content yet, and the color its content takes. +/// `label` names the chip for assistive technology. +fn chip_frame( + id: &'static str, + label: SharedString, + active: bool, + highlight: bool, +) -> (gpui::Stateful
, u32) { let color = if highlight { theme::accent() } else if active { @@ -1210,12 +1196,11 @@ fn chip( } else { theme::text_secondary() }; - let label = label.into(); - div() + let frame = div() .id(id) .debug_selector(|| id.to_string()) .role(gpui::Role::Button) - .aria_label(label.clone()) + .aria_label(label) .h_8() .flex() .items_center() @@ -1231,8 +1216,6 @@ fn chip( .bg(gpui::rgb(theme::bg_sidebar_pill())) .cursor_pointer() }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - .children(leading.map(|name| icon(name, px(16.), color))) - .child(div().whitespace_nowrap().child(label)) - .when(chevron, |el| el.child(icon("chevron-down", px(14.), color))) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))); + (frame, color) } diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index b02aaddca..763ecbaf2 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -3,8 +3,11 @@ //! confirmations, archiving, deleting, and leaving a task. They touch //! the canonical session list and the project context, which live here. +use std::sync::Arc; + use gpui::{Context, Div, div, prelude::*, px}; use maple_agent::agent::{AgentProjectTrustStatus, AgentTaskState}; +use maple_agent::host::HostBackend; use super::ChatScreen; use crate::ui::theme; @@ -37,10 +40,9 @@ impl ChatScreen { let Some(root) = self.project_root.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.project_trust(&user_id, root).await }, + async move { host.project_trust(root).await }, cx, |this, result, cx| { if let Ok(status) = result @@ -70,10 +72,9 @@ impl ChatScreen { } self.trust_saving = true; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.set_project_trust(&user_id, path, trusted).await }, + async move { host.set_project_trust(path, trusted).await }, cx, move |this, result, cx| { this.trust_saving = false; @@ -409,16 +410,11 @@ impl ChatScreen { state: AgentTaskState, cx: &mut Context, ) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let changed_id = session_id.clone(); self.call( - async move { - backend - .set_session_state(&user_id, &session_id, state) - .await - }, + async move { host.set_session_state(session_id.clone(), state).await }, cx, move |this, result, cx| { match result { @@ -451,12 +447,11 @@ impl ChatScreen { /// Delete one task for good. The runtime refuses while it runs, so /// the row leaves the list only after the backend says it is gone. pub(super) fn delete_task(&mut self, session_id: &str, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let deleted_id = session_id.clone(); self.call( - async move { backend.delete_session(&user_id, &session_id).await }, + async move { host.delete_session(session_id).await }, cx, move |this, result, cx| { match result { @@ -510,8 +505,10 @@ impl ChatScreen { cx.notify(); return; } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + // The project leaves the target host's list; each task under it + // archives on the host that owns it, since the same path may hold + // tasks of several hosts. + let host = self.host.clone(); let path = root.to_string(); let fallback = self .recent_roots @@ -530,23 +527,28 @@ impl ChatScreen { .filter(|s| s.state != AgentTaskState::Archived && s.project_root == root) .map(|s| s.id.clone()) .collect(); + let archives: Vec<(Arc, String)> = task_ids + .iter() + .map(|id| (self.backend_for(id), id.clone())) + .collect(); let removed = path.clone(); let next_root = fallback.clone(); - let removed_task_ids = task_ids.clone(); + let removed_task_ids = task_ids; self.call( async move { - for id in task_ids { - backend - .set_session_state(&user_id, &id, AgentTaskState::Archived) + for (owner, id) in archives { + owner + .set_session_state(id, AgentTaskState::Archived) .await?; } - backend.remove_project_root(&user_id, path, fallback).await + host.remove_project_root(path, fallback).await }, cx, move |this, result, cx| { match result { Ok(()) => { this.recent_roots.retain(|candidate| candidate != &removed); + this.cache_target_context(); for session in &mut this.sessions { if session.project_root == removed { session.state = AgentTaskState::Archived; diff --git a/apps/maple-agent/app/src/ui/chat/hosts.rs b/apps/maple-agent/app/src/ui/chat/hosts.rs new file mode 100644 index 000000000..487a81490 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/hosts.rs @@ -0,0 +1,761 @@ +//! The hosts the chat screen shows tasks from: the local host and every +//! saved remote host, which one new tasks target, the sidebar's host +//! filter, and the header's host chip. Calls about a task go to the host +//! that owns it, whatever host new tasks target. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use gpui::{Context, Div, SharedString, Window, div, prelude::*, px}; +use maple_agent::agent::AgentSessionSummary; +use maple_agent::host::{HostBackend, HostBootstrap, HostEvent, HostId, HostSessionDefaults}; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::{HostManagerEvent, HostStatus}; + +use super::{ChatScreen, OpenSettingsSection, Section, sidebar}; +use crate::ui::popup::{Menu, MenuItem}; +use crate::ui::theme; + +/// What the local host is called wherever hosts are listed. +pub(super) const LOCAL_HOST_NAME: &str = "This computer"; + +/// One host the chat screen shows tasks from: the local host always, and +/// each remote host as it connects or as a saved entry waiting to. +pub(super) struct ChatHost { + /// Absent for a saved host that is not connected. + pub(super) backend: Option>, + pub(super) name: String, + pub(super) online: bool, + /// Bumped on every connection state change. A call made on one + /// connection whose answer lands on another is dropped: the host was + /// re-read when it came back. + pub(super) connection: u64, + /// The host's project context, as its bootstrap reported it and as + /// the user changed it while the host was the target; adopted again + /// when the host becomes the target. + pub(super) project_root: Option, + pub(super) recent_roots: Vec, + pub(super) session_defaults: Option, +} + +impl ChatHost { + pub(super) fn local(backend: Arc) -> Self { + Self { + backend: Some(backend), + name: LOCAL_HOST_NAME.to_string(), + online: true, + connection: 0, + project_root: None, + recent_roots: Vec::new(), + session_defaults: None, + } + } + + pub(super) fn saved(name: String) -> Self { + Self { + backend: None, + name, + online: false, + connection: 0, + project_root: None, + recent_roots: Vec::new(), + session_defaults: None, + } + } +} + +/// What reading a freshly connected host produced. +pub(super) struct RemoteBootstrap { + pub(super) boot: HostBootstrap, + /// Why its runtime did not start, if it did not. + pub(super) start_error: Option, + /// Stored tool summaries of its latest task, when that task will open. + pub(super) summaries: HashMap, +} + +impl ChatScreen { + /// Every known host, local first, then by name. + pub(super) fn sorted_hosts(&self) -> Vec { + let mut hosts: Vec = self + .hosts + .iter() + .map(|(id, entry)| sidebar::SidebarHost { + id: id.clone(), + name: SharedString::from(entry.name.clone()), + online: entry.online, + }) + .collect(); + hosts.sort_by(|a, b| { + b.id.is_local() + .cmp(&a.id.is_local()) + .then_with(|| a.name.as_ref().cmp(b.name.as_ref())) + }); + hosts + } + + /// `hosts` changed (a host came, went, or was renamed): rebuild what + /// renders from it. + pub(super) fn hosts_changed(&mut self) { + self.host_list = self.sorted_hosts(); + self.hosts_dirty = true; + self.refresh_target_host_label(); + } + + pub(super) fn refresh_target_host_label(&mut self) { + self.target_host_label = SharedString::from(self.host_name(&self.target_host)); + // A rename or a first status reaches the badge through here too. + if let Some((owner, _)) = self.selected_host.clone() { + self.selected_host = Some((owner.clone(), SharedString::from(self.host_name(&owner)))); + } + } + + pub(super) fn host_name(&self, id: &HostId) -> String { + self.hosts + .get(id) + .map(|entry| entry.name.clone()) + .unwrap_or_else(|| id.to_string()) + } + + /// The host that owns `session_id`; the local host when unknown. + pub(super) fn host_of(&self, session_id: &str) -> HostId { + self.session_hosts + .get(session_id) + .cloned() + .unwrap_or_else(HostId::local) + } + + /// The backend that owns `session_id`: every call about that task goes + /// there, whatever host new tasks target. The target's backend stands + /// in for a task whose host is unknown. + pub(super) fn backend_for(&self, session_id: &str) -> Arc { + let owner = self.host_of(session_id); + self.hosts + .get(&owner) + .and_then(|entry| entry.backend.clone()) + .unwrap_or_else(|| self.host.clone()) + } + + /// The backend of the task on screen; the target's with none. + pub(super) fn session_backend(&self) -> Arc { + match self.selected_session.as_deref() { + Some(session_id) => self.backend_for(session_id), + None => self.host.clone(), + } + } + + /// File `session_id` under `host` unless it has a host already. + pub(super) fn file_session(&mut self, session_id: &str, host: &HostId) { + if self.session_hosts.contains_key(session_id) { + return; + } + self.session_hosts + .insert(session_id.to_string(), host.clone()); + self.hosts_dirty = true; + } + + /// Make `host` the target of new tasks. The selected task's host is + /// the target unless the sidebar filters on one. Returns whether + /// `host` is the target: an offline or unknown host cannot take new + /// tasks and is refused. + pub(super) fn set_target_host(&mut self, host: HostId, cx: &mut Context) -> bool { + if self.target_host == host { + return true; + } + let Some((backend, recent_roots, defaults)) = self + .hosts + .get(&host) + .filter(|entry| entry.online) + .and_then(|entry| { + Some(( + entry.backend.clone()?, + entry.recent_roots.clone(), + entry.session_defaults.clone(), + )) + }) + else { + return false; + }; + self.target_host = host; + self.host = backend; + self.popup.close(cx); + self.refresh_target_host_label(); + self.recent_roots = recent_roots; + // New tasks take this host's defaults (web access, permission + // mode), whichever task is on screen. + if let Some(defaults) = defaults { + self.apply_session_defaults(&defaults, cx); + } + self.refresh_roots(cx); + self.refresh_slash_commands(cx); + self.sync_sidebar(cx); + true + } + + /// Show the target host's saved project, for when the target changed + /// without a task selection. + pub(super) fn adopt_target_host_context(&mut self, cx: &mut Context) { + let Some(root) = self + .hosts + .get(&self.target_host) + .map(|entry| entry.project_root.clone()) + else { + return; + }; + self.set_project_context(root, cx); + } + + /// The sidebar filters on `host` (or on none): new tasks go there. A + /// filter on an offline host is refused, since it could not take them; + /// the sidebar then shows the filter that stands. + pub(super) fn set_host_filter(&mut self, host: Option, cx: &mut Context) { + let target = match &host { + Some(host) => host.clone(), + None => self + .selected_session + .as_deref() + .map(|id| self.host_of(id)) + .filter(|owner| self.hosts.get(owner).is_some_and(|entry| entry.online)) + .unwrap_or_else(HostId::local), + }; + let changed = target != self.target_host; + if !self.set_target_host(target.clone(), cx) { + self.notice = Some(format!("{} is offline", self.host_name(&target)).into()); + self.sync_host_filter(cx); + cx.notify(); + return; + } + self.host_filter = host; + if changed { + self.follow_target_change(cx); + } + cx.notify(); + } + + /// Push the host filter to the sidebar, which shows it. + pub(super) fn sync_host_filter(&mut self, cx: &mut Context) { + let filter = self.host_filter.clone(); + self.sidebar + .update(cx, |sidebar, cx| sidebar.show_host_filter(filter, cx)); + } + + /// `host` cannot be filtered on any more (offline or removed): a + /// filter naming it goes, on screen and in the sidebar. + pub(super) fn clear_host_filter_for(&mut self, host: &HostId, cx: &mut Context) { + if self.host_filter.as_ref() != Some(host) { + return; + } + self.host_filter = None; + self.sync_host_filter(cx); + } + + /// `host` dropped while it was the target: new tasks go to the local + /// host, and with no task on screen the header shows its project. + pub(super) fn fall_back_to_local_host(&mut self, host: &HostId, cx: &mut Context) { + self.clear_host_filter_for(host, cx); + if self.target_host != *host { + return; + } + self.set_target_host(HostId::local(), cx); + if self.selected_session.is_none() { + self.follow_target_change(cx); + } + } + + /// Hosts a settings screen can point at: every connected one. + pub(crate) fn connected_hosts(&self) -> Vec { + self.host_list + .iter() + .filter(|host| host.online) + .filter_map(|host| { + Some(crate::ui::settings::SettingsHost { + id: host.id.clone(), + name: host.name.to_string(), + backend: self.hosts.get(&host.id)?.backend.clone()?, + }) + }) + .collect() + } + + /// The saved host list changed: list saved hosts that are not + /// connected as offline, and drop hosts that were removed. + pub fn set_saved_hosts(&mut self, saved: Vec, cx: &mut Context) { + let keep: HashSet = saved + .iter() + .map(|host| HostId::new(host.id.clone())) + .chain(std::iter::once(HostId::local())) + .collect(); + let removed: Vec = self + .hosts + .keys() + .filter(|id| !keep.contains(id)) + .cloned() + .collect(); + for id in removed { + self.drop_host_sessions(&id); + self.hosts.remove(&id); + self.fall_back_to_local_host(&id, cx); + } + // A remembered host that is not saved any more is not coming back. + if let Some(host) = self + .restore_host + .clone() + .filter(|host| !keep.contains(host)) + { + self.give_up_restore(&host, cx); + } + for host in saved { + let id = HostId::new(host.id); + match self.hosts.get_mut(&id) { + Some(entry) => entry.name = host.name, + None => { + self.hosts.insert(id, ChatHost::saved(host.name)); + } + } + } + self.hosts_changed(); + self.sync_sidebar(cx); + cx.notify(); + } + + /// A remote host's connection changed. Online: adopt it and read its + /// tasks. Otherwise its tasks leave the list until it is back; the + /// task on screen stays readable. + pub fn set_remote_host_status( + &mut self, + host: HostId, + name: String, + status: HostStatus, + backend: Option>, + cx: &mut Context, + ) { + let online = status == HostStatus::Online; + let entry = self + .hosts + .entry(host.clone()) + .or_insert_with(|| ChatHost::saved(name.clone())); + let was_online = entry.online; + entry.name = name; + entry.online = online; + entry.connection += 1; + let connection = entry.connection; + if let Some(backend) = backend { + entry.backend = Some(backend); + } + self.hosts_changed(); + if online { + self.bootstrap_remote_host(host, connection, cx); + } else { + self.drop_host_sessions(&host); + self.fall_back_to_local_host(&host, cx); + if let HostStatus::Offline { reason } = status { + self.give_up_restore(&host, cx); + // The drop itself is news; the reconnect attempts that + // follow report the same thing until the host is back. + if was_online && reason != "removed" { + self.notice = Some(format!("{}: {reason}", self.host_name(&host)).into()); + } + } + self.sync_sidebar(cx); + } + cx.notify(); + } + + /// Forget a host's tasks in the list and their runs. The selected task + /// keeps its host mapping so its screen stays coherent. + pub(super) fn drop_host_sessions(&mut self, host: &HostId) { + let selected = self.selected_session.clone(); + let gone: HashSet = self + .session_hosts + .iter() + .filter(|(_, owner)| *owner == host) + .map(|(id, _)| id.clone()) + .collect(); + self.sessions.retain(|session| !gone.contains(&session.id)); + for id in &gone { + self.active_runs.remove(id); + self.completed_unread_sessions.remove(id); + if selected.as_deref() != Some(id.as_str()) { + self.session_hosts.remove(id); + self.hosts_dirty = true; + } + } + } + + /// Read a freshly connected host: its tasks, roots, and defaults, and + /// start its runtime so it can run them. `connection` names the + /// connection the read is for; an answer from an earlier one is stale. + pub(super) fn bootstrap_remote_host( + &mut self, + host: HostId, + connection: u64, + cx: &mut Context, + ) { + let Some(backend) = self + .hosts + .get(&host) + .and_then(|entry| entry.backend.clone()) + else { + return; + }; + let target = host.clone(); + let restoring = self.restore_host.as_ref() == Some(&host); + self.call( + async move { + let boot = backend.bootstrap().await?; + let start_error = backend.start_runtime(None).await.err(); + // Only the restored host opens its latest task, so only it + // needs that task's stored summaries. + let summaries = match boot.latest.as_ref().filter(|_| restoring) { + Some(detail) => backend + .tool_summaries(detail.session.id.clone()) + .await + .unwrap_or_else(|error| { + log::warn!("Cannot load tool summaries: {error}"); + HashMap::new() + }), + None => HashMap::new(), + }; + Ok::<_, String>(RemoteBootstrap { + boot, + start_error, + summaries, + }) + }, + cx, + move |this, result, cx| this.finish_remote_bootstrap(target, connection, result, cx), + ); + } + + /// Whether `host` is still on the connection a call was made on. + pub(super) fn on_connection(&self, host: &HostId, connection: u64) -> bool { + self.hosts + .get(host) + .is_some_and(|entry| entry.connection == connection) + } + + /// The bootstrap of `target` came back. A failure releases a startup + /// held for that host: it will not open its task. An answer from a + /// connection that has since dropped or been replaced is stale: the + /// host's tasks left with it, or its new connection reads it afresh. + pub(super) fn finish_remote_bootstrap( + &mut self, + target: HostId, + connection: u64, + result: Result, + cx: &mut Context, + ) { + if !self.on_connection(&target, connection) { + return; + } + match result { + Ok(RemoteBootstrap { + boot, + start_error, + summaries, + }) => { + self.apply_remote_bootstrap(target, boot, start_error, summaries, cx); + } + Err(message) => { + self.notice = Some(format!("{}: {message}", self.host_name(&target)).into()); + self.give_up_restore(&target, cx); + } + } + cx.notify(); + } + + /// A remote host answered its bootstrap. When it is the host the last + /// new task ran on, it becomes the target again and its latest task + /// opens, unless a task was chosen meanwhile or the user started a + /// draft: that draft follows the target, its text kept. + pub(super) fn apply_remote_bootstrap( + &mut self, + target: HostId, + boot: HostBootstrap, + start_error: Option, + summaries: HashMap, + cx: &mut Context, + ) { + if let Some(entry) = self.hosts.get_mut(&target) { + entry.project_root = boot.project_root.clone(); + entry.recent_roots = boot.recent_roots.clone(); + entry.session_defaults = Some(boot.session_defaults.clone()); + } + self.apply_host_session_list(&target, boot.sessions, cx); + if let Some(error) = start_error { + self.notice = Some( + format!( + "{}: runtime failed to start: {error}", + self.host_name(&target) + ) + .into(), + ); + } + let restoring = self.restore_host.as_ref() == Some(&target); + if restoring { + self.restore_host = None; + if self.selected_session.is_none() && self.host_filter.is_none() { + self.set_target_host(target.clone(), cx); + } + } + if self.target_host == target { + self.recent_roots = boot.recent_roots; + self.adopt_target_host_context(cx); + match boot + .latest + .filter(|_| restoring && self.selected_session.is_none() && !self.draft) + { + Some(detail) => { + let summaries = summaries + .into_iter() + .map(|(id, summary)| (id, SharedString::from(summary))) + .collect(); + self.upsert_session(detail.session.clone(), cx); + self.set_active_session(detail.session, detail.timeline, summaries, cx); + self.queue = detail.queue.items; + } + // The draft on screen is for this host: its chip lists + // what a task created there starts with. + None if self.selected_session.is_none() => self.refresh_draft_mcp(cx), + None => {} + } + } + } + + /// The remembered host will not come: stop holding startup for it and + /// let the local auto-select run. + pub(super) fn give_up_restore(&mut self, host: &HostId, cx: &mut Context) { + if self.restore_host.as_ref() != Some(host) { + return; + } + self.restore_host = None; + if self.selected_session.is_none() { + self.refresh_sessions(cx); + } + } + + /// Everything the connection manager reports, in one batch. A repaint + /// is requested once per batch, however many events change something. + pub fn handle_manager_events(&mut self, events: Vec, cx: &mut Context) { + for event in events { + match event { + HostManagerEvent::Event { host, event } => { + self.handle_remote_host_events(host, vec![event], cx); + } + HostManagerEvent::Status { + host, + name, + status, + backend, + } => self.set_remote_host_status( + host, + name, + status, + backend.map(|backend| backend as Arc), + cx, + ), + HostManagerEvent::HostsChanged(hosts) => self.set_saved_hosts(hosts, cx), + } + } + } + + /// Events from a remote host; dropped once it is offline. + pub fn handle_remote_host_events( + &mut self, + host: HostId, + events: Vec, + cx: &mut Context, + ) { + if !self.hosts.get(&host).is_some_and(|entry| entry.online) { + return; + } + self.apply_host_events(&host, events, cx); + } + + /// Replace one host's tasks in the merged list. A task the list names + /// leaves whatever host it was filed under: the list is the truth + /// about where it lives, and one row per task is the invariant. + pub(super) fn apply_host_session_list( + &mut self, + host: &HostId, + sessions: Vec, + cx: &mut Context, + ) { + let listed: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); + let local = HostId::local(); + let session_hosts = &self.session_hosts; + self.sessions.retain(|session| { + !listed.contains(session.id.as_str()) + && session_hosts.get(&session.id).unwrap_or(&local) != host + }); + // A task the host no longer lists is gone from it; the task on + // screen keeps its mapping so its calls still know where to go. + let selected = self.selected_session.clone(); + self.session_hosts.retain(|id, owner| { + owner != host || listed.contains(id.as_str()) || selected.as_deref() == Some(id) + }); + for session in &sessions { + self.session_hosts.insert(session.id.clone(), host.clone()); + } + self.hosts_dirty = true; + self.sessions.extend(sessions); + self.sessions + .sort_by_key(|session| std::cmp::Reverse(session.updated_ms)); + self.sync_sidebar(cx); + } + + /// Re-read every connected host's task list. The sidebar groups tasks + /// by project, so each host lists every root; a task's stored root + /// remains authoritative when it is opened or run. + pub(super) fn refresh_sessions(&self, cx: &mut Context) { + let generation = self.selection_generation; + for (id, entry) in &self.hosts { + let Some(backend) = entry.backend.clone().filter(|_| entry.online) else { + continue; + }; + let id = id.clone(); + let connection = entry.connection; + self.call( + async move { backend.list_sessions(None).await }, + cx, + move |this, result, cx| { + this.apply_listed_sessions(&id, connection, generation, result, cx) + }, + ); + } + } + + /// One host answered `refresh_sessions`. A list from a connection that + /// has since changed is stale: the host's tasks left with it, or its + /// new connection lists them again. + pub(super) fn apply_listed_sessions( + &mut self, + host: &HostId, + connection: u64, + generation: u64, + result: Result, String>, + cx: &mut Context, + ) { + if !self.on_connection(host, connection) { + return; + } + match result { + Ok(sessions) if host.is_local() => self.apply_session_list(sessions, generation, cx), + Ok(sessions) => self.apply_host_session_list(host, sessions, cx), + Err(message) => self.notice = Some(message.into()), + } + cx.notify(); + } + + /// The user chose a host in the header chip: new tasks go there, and + /// the project context follows that host. + pub(super) fn pick_host(&mut self, host: HostId, cx: &mut Context) { + self.popup.close(cx); + let changed = host != self.target_host; + if !self.set_target_host(host.clone(), cx) { + self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); + } else if changed { + self.follow_target_change(cx); + } + cx.notify(); + } + + /// The target moved: show its project and defaults. No task exists + /// until the first message is sent, so the draft on screen simply + /// follows, keeping its text; the message will run where the header + /// says, and its chip lists what a task created there starts with. + pub(super) fn follow_target_change(&mut self, cx: &mut Context) { + if self.selection_is_draft() { + // An empty task pinned to the old target would take the first + // message there; leave it and let the send create the task + // on the new one. What replaces it is a draft: the new host's + // task list must not open a task over the text typed so far. + self.clear_selected_session_presentation(cx); + self.draft = true; + } + self.adopt_target_host_context(cx); + if self.draft { + self.refresh_draft_mcp(cx); + } + } + + pub(super) fn target_host_online(&self) -> bool { + self.hosts + .get(&self.target_host) + .is_some_and(|entry| entry.online) + } + + /// The host chip's menu: every known host with its state, then a way + /// to the Hosts settings. Opened through the chat's popup + /// (`ChatPopup::Host`). + pub(super) fn host_menu(&self) -> Menu { + let mut menu = Menu::new("host-menu", px(300.)) + .label("Hosts") + .application_vim(self.application_vim_enabled) + .header("NEW TASKS RUN ON"); + for host in &self.host_list { + let online = host.online; + let pick = host.id.clone(); + menu = menu.item( + MenuItem::new( + SharedString::from(format!("host-menu-{}", host.id)), + host.name.clone(), + move |this: &mut Self, _: &mut Window, cx: &mut Context| { + this.pick_host(pick.clone(), cx); + }, + ) + .content( + div() + .flex() + .items_center() + .gap_2() + .child(status_dot(online)) + .child( + div() + .flex_1() + .min_w_0() + .line_clamp(1) + .text_ellipsis() + .child(host.name.clone()), + ) + .when(!online, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("offline"), + ) + }), + ) + .current(host.id == self.target_host) + .style(move |row| { + row.when(!online, |row| { + row.text_color(gpui::rgb(theme::text_muted())) + }) + }), + ); + } + menu.separator().item( + MenuItem::new( + "host-menu-manage", + "Manage hosts\u{2026}", + |_: &mut Self, _: &mut Window, cx: &mut Context| { + cx.emit(OpenSettingsSection(Section::Hosts)); + }, + ) + .style(|row| row.text_color(gpui::rgb(theme::text_secondary()))), + ) + } +} + +/// A small live-state dot: green online, muted otherwise. +pub(super) fn status_dot(online: bool) -> Div { + div() + .flex_none() + .size(px(8.)) + .rounded_full() + .bg(gpui::rgb(if online { + theme::status_success() + } else { + theme::text_muted() + })) +} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index d61456762..310b6a0f1 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -17,6 +17,7 @@ use maple_agent::agent::{ AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentSubagent, AgentTaskState, AgentTimelineItem, SideQuestionEvent, }; +use maple_agent::host::{HostBackend, HostEvent, HostId, HostSessionDefaults}; use crate::backend::{AgentBackend, PendingPermission, PendingQuestion}; use crate::ui::icons::{icon, spinner, wordmark}; @@ -33,8 +34,10 @@ mod cache; mod commands; mod composer; mod dialogs; +mod hosts; mod images; mod navigation; +mod picker; mod queue; mod sidebar; mod speech; @@ -46,7 +49,9 @@ mod transcript; use self::cache::{DerivedCache, INLINE_PARSE_LIMIT, MarkdownCache, MarkdownKind}; use self::commands::ChatCommand; use self::composer::{SideQuestionPanel, SlashEntry, slash_entries_for}; +use self::hosts::{ChatHost, LOCAL_HOST_NAME}; use self::navigation::ApplicationVimState; +use self::picker::ProjectPicker; #[cfg(test)] use self::sidebar::SessionActivity; use self::sidebar::{Sidebar, SidebarEvent, root_display_name, session_summary_eq}; @@ -106,9 +111,6 @@ const CONTENT_WIDTH: gpui::Pixels = px(900.); /// Header title when no task is selected. const DEFAULT_TASK_TITLE: &str = "New Task"; -/// Recent projects the project menu lists above "New project…". -pub(super) const ROOT_MENU_RECENTS: usize = 6; - /// How long a notice stays before it clears itself. const NOTICE_TTL: std::time::Duration = std::time::Duration::from_secs(8); @@ -168,27 +170,10 @@ pub(crate) struct SpeechState { pub playing: bool, } -#[derive(Default)] -pub(super) struct QuestionSelection { - cursor: Option, - picked: BTreeSet, -} - -/// The chat's popup menus. One is open at a time. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) enum ChatPopup { - /// The header chip's project menu. - Project, - Model, - Mode, - Integrations, - /// The transcript's right-click menu, at a window position. - Transcript(gpui::Point), -} - /// What the composer asked for while no task existed. The first send -/// creates the task and then runs this against it. The composer keeps -/// showing the text meanwhile; it clears when the send goes out. +/// creates the task on the target host and then runs this against it. +/// The composer keeps showing the text meanwhile; it clears when the +/// send goes out. #[derive(Debug, Clone, PartialEq, Eq)] enum FirstSend { /// Plain text (with the staged images) for the new task. @@ -237,7 +222,7 @@ impl NewSessionChanges { } /// The row the draft's integrations chip shows for a configured MCP -/// server: what a new task would start with. +/// server: what a task created on the target host would start with. fn draft_mcp_row(server: AgentMcpServer) -> AgentSessionMcpServer { AgentSessionMcpServer { display_name: server.name.clone(), @@ -254,6 +239,24 @@ fn draft_mcp_row(server: AgentMcpServer) -> AgentSessionMcpServer { } } +#[derive(Default)] +pub(super) struct QuestionSelection { + cursor: Option, + picked: BTreeSet, +} + +/// The chat's popup menus. One is open at a time. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum ChatPopup { + /// The header chip's host menu. + Host, + Model, + Mode, + Integrations, + /// The transcript's right-click menu, at a window position. + Transcript(gpui::Point), +} + /// The curated CUA integration's id, which is also the name the runtime /// reads it under in a create request's server list. const CUA_DRIVER_ID: &str = "cua-driver"; @@ -320,7 +323,33 @@ fn draft_mcp_rows( } pub struct ChatScreen { + /// Account-level calls: sign-out, billing, audio. backend: Arc, + /// The target host's backend: where new tasks go and whose project + /// context the header shows. Calls about a task go to the host that + /// owns it, through [`Self::backend_for`] or [`Self::session_backend`]. + host: Arc, + /// Every host this screen knows, keyed by host id. + hosts: HashMap, + /// Which host owns each task in `sessions`. + session_hosts: HashMap, + /// Host new tasks go to: the sidebar's host filter when set, else the + /// selected task's host, else the local host. + target_host: HostId, + /// The sidebar's host filter, so a task selection does not override it. + host_filter: Option, + /// Every known host, local first, then by name: what the sidebar + /// lists, the header chip offers, and Settings points at. Rebuilt + /// when `hosts` changes, not per frame. + host_list: Vec, + /// `host_list` or `session_hosts` changed since the sidebar last saw + /// them; the next sync pushes both. + hosts_dirty: bool, + /// The target host's name, for the header chip and the picker. + target_host_label: SharedString, + /// The host of the task on screen and its name, for the header badge; + /// `None` on the new-task screen, where the header shows the target. + selected_host: Option<(HostId, SharedString)>, user_id: String, /// The task list; its own entity so it renders only when it changes. sidebar: Entity, @@ -352,10 +381,10 @@ pub struct ChatScreen { /// The send that is creating the task, run once the task lands. A /// selection change meanwhile drops it. pending_first_send: Option, - /// The integration rows a new task would start with, once the draft's - /// chip has loaded them. `None` until then: the create request then - /// names no servers and the task takes the defaults, rather than an - /// empty list that would start nothing. + /// The integration rows a new task would start with on the target + /// host, once the draft's chip has loaded them. `None` until then: the + /// create request then names no servers and the task takes the host's + /// defaults, rather than an empty list that would start nothing. draft_mcp_defaults: Option>, /// Integration toggles made on the draft, applied over the defaults /// (they survive a reload of the rows). The MCP servers switched on go @@ -430,8 +459,8 @@ pub struct ChatScreen { /// Existing tasks always execute in their own persisted project root. project_root: Option, recent_roots: Vec, - /// Which of the chat's popup menus is open: the header's project menu, - /// a composer chip's menu, or the transcript's right-click menu. + /// Which of the chat's popup menus is open: the header's host menu, a + /// composer chip's menu, or the transcript's right-click menu. popup: Popup, /// Focus for whichever modal dialog is open, so Enter and Escape /// reach it instead of the composer. Created the first time a dialog @@ -443,10 +472,16 @@ pub struct ChatScreen { /// Whether the project-trust question may open its dialog. Tests that /// drive typing turn it off, since the dialog rightly takes focus. trust_prompts: bool, - /// Manual path entry for the project selector. + /// The project picker's search box, created once. root_input: Option>, - /// The path field was just offered; the next render gives it focus. + /// The picker just opened; the next render moves keyboard focus into + /// its search box so typing does not land in the composer. root_input_focus_pending: bool, + /// The project picker, while open. + project_picker: Option, + /// The remote host the last new task ran on, until it connects and + /// becomes the target again. Startup holds its auto-select for it. + restore_host: Option, root_selecting: bool, /// Header label for the project root; set when the root changes so /// render does not format it. @@ -456,13 +491,9 @@ pub struct ChatScreen { project_branch: Option, /// `project_branch` in parentheses, ready for the header. branch_label: Option, - /// Watches the git dir of the current root so a checkout by the agent - /// or from a terminal updates the branch. Replaced when the git dir - /// changes, dropped with the root. - branch_watcher: Option, - watched_git_dir: Option, - /// A native folder picker is open; more clicks must not open another. - root_picker_open: bool, + /// Root whose branch the host reports to this screen; the host owns + /// the git dir watch. Replaced when the root changes. + watched_root: Option, /// Sidebar hidden; a toggle in the main pane brings it back. sidebar_collapsed: bool, /// Images staged for the next message. @@ -633,8 +664,13 @@ const LOAD_RETRIES: u8 = 2; const FINISHED_RUNS_KEPT: usize = 64; impl ChatScreen { - pub fn new(backend: Arc, user_id: String, cx: &mut Context) -> Self { - let this = Self::new_mounted(backend, user_id, cx); + pub fn new( + backend: Arc, + host: Arc, + user_id: String, + cx: &mut Context, + ) -> Self { + let this = Self::new_mounted(backend, host, user_id, cx); this.start(cx); this } @@ -643,9 +679,14 @@ impl ChatScreen { /// Production immediately calls `start`; focused GPUI tests use the /// deterministic seam so unrelated Tokio scheduling cannot replace their /// fixture state mid-interaction. - fn new_mounted(backend: Arc, user_id: String, cx: &mut Context) -> Self { + fn new_mounted( + backend: Arc, + host: Arc, + user_id: String, + cx: &mut Context, + ) -> Self { let weak = cx.entity().downgrade(); - let mut this = Self::new_inner(backend, user_id, cx); + let mut this = Self::new_inner(backend, host, user_id, cx); this.attach_composer(weak.clone(), cx); this.markdown_cache.attach(weak.clone(), cx.to_async()); this.selection = Some(cx.new(|_| rich_text::TextSelection::default())); @@ -658,10 +699,11 @@ impl ChatScreen { #[cfg(test)] pub(crate) fn new_without_start( backend: Arc, + host: Arc, user_id: String, cx: &mut Context, ) -> Self { - Self::new_mounted(backend, user_id, cx) + Self::new_mounted(backend, host, user_id, cx) } /// What the sidebar asked for. Events arrive after the sidebar's own @@ -674,9 +716,8 @@ impl ChatScreen { ) { match event.clone() { SidebarEvent::Select(id) => self.select_session(&id, cx), - SidebarEvent::SessionChanged(session) => { - self.upsert_session(session, cx); - cx.notify(); + SidebarEvent::RenameTask { session_id, name } => { + self.rename_session(session_id, name, cx); } SidebarEvent::SetState { session_id, state } => { self.set_session_state(&session_id, state, cx) @@ -684,11 +725,13 @@ impl ChatScreen { SidebarEvent::DeleteTask(id) => self.request_delete_task(&id, cx), SidebarEvent::RemoveRoot(root) => self.request_remove_root(&root, cx), SidebarEvent::SetTrust { path, trusted } => self.set_project_trust(path, trusted, cx), - SidebarEvent::ChooseProject => self.choose_root_dialog(cx), + SidebarEvent::ProjectTrust(root) => self.answer_project_trust(root, cx), + SidebarEvent::ChooseProject => self.open_project_picker(cx), SidebarEvent::Notice(message) => { self.notice = Some(message); cx.notify(); } + SidebarEvent::HostFilter(host) => self.set_host_filter(host, cx), } } @@ -701,11 +744,18 @@ impl ChatScreen { let running: HashSet = self.active_runs.keys().cloned().collect(); let unread = self.completed_unread_sessions.clone(); let roots = self.recent_roots.clone(); + // The host list and the task-to-host map change rarely; they are + // pushed only when they did. + let hosts = std::mem::take(&mut self.hosts_dirty) + .then(|| (self.host_list.clone(), self.session_hosts.clone())); self.sidebar.update(cx, |sidebar, cx| { sidebar.set_sessions(sessions, cx); sidebar.set_selected(selected, cx); sidebar.set_activity(running, unread, cx); sidebar.set_recent_roots(roots, cx); + if let Some((hosts, session_hosts)) = hosts { + sidebar.set_hosts(hosts, session_hosts, cx); + } }); self.refresh_selected_title(); } @@ -987,24 +1037,40 @@ impl ChatScreen { /// Test seam: pure state without composer wiring or runtime start. pub(crate) fn new_inner( backend: Arc, + host: Arc, user_id: String, cx: &mut Context, ) -> Self { - Self::new_inner_with_placeholder(backend, user_id, cx) + Self::new_inner_with_placeholder(backend, host, user_id, cx) } fn new_inner_with_placeholder( backend: Arc, + host: Arc, user_id: String, cx: &mut Context, ) -> Self { let settings = crate::settings::load_settings(); let weak = cx.entity().downgrade(); - let sidebar = - cx.new(|cx| Sidebar::new(backend.clone(), user_id.clone(), weak, &settings, cx)); + let sidebar = cx.new(|cx| Sidebar::new(backend.clone(), weak, &settings, cx)); cx.subscribe(&sidebar, Self::on_sidebar_event).detach(); - Self { + let hosts = HashMap::from([(HostId::local(), ChatHost::local(host.clone()))]); + let restore_host = settings + .last_task_host + .as_deref() + .map(HostId::new) + .filter(|host| !host.is_local()); + let mut this = Self { backend, + host, + hosts, + session_hosts: HashMap::new(), + target_host: HostId::local(), + host_filter: None, + host_list: Vec::new(), + hosts_dirty: false, + target_host_label: LOCAL_HOST_NAME.into(), + selected_host: None, user_id, sidebar, sessions: Vec::new(), @@ -1046,12 +1112,13 @@ impl ChatScreen { loading_session: None, list_state: transcript_list_state(), tool_details: settings.tool_details, - // An unset or unknown value in either place means "use the - // saved default", so only a known mode counts as an override. + // The host's saved default arrives with the bootstrap; until + // then the safer mode applies. An unknown value in the + // environment means "use the saved default", so only a known + // mode counts as an override. permission_mode: std::env::var("MAPLE_PERMISSION_MODE") .ok() .and_then(|mode| PermissionMode::from_str(&mode)) - .or(Some(settings.default_permission_mode)) .unwrap_or_default(), uses_default_permission_mode: std::env::var("MAPLE_PERMISSION_MODE").is_err(), project_root: None, @@ -1069,7 +1136,8 @@ impl ChatScreen { mcp_enabled_count: 0, composer_expanded: false, web_enabled: true, - default_web_enabled: settings.default_web_enabled, + // The host's saved default arrives with the bootstrap. + default_web_enabled: true, markdown_cache: MarkdownCache::default(), notice_dismiss_pending: std::cell::Cell::new(false), derived: DerivedCache::default(), @@ -1078,13 +1146,13 @@ impl ChatScreen { selected_title: DEFAULT_TASK_TITLE.into(), root_input: None, root_input_focus_pending: false, + project_picker: None, + restore_host, root_selecting: false, project_label: SharedString::from("Choose folder"), project_branch: None, branch_label: None, - branch_watcher: None, - watched_git_dir: None, - root_picker_open: false, + watched_root: None, selection_generation: 0, reload_generation: 0, attachment_images: HashMap::new(), @@ -1137,7 +1205,9 @@ impl ChatScreen { speech_generation: 0, tts_voice: settings.tts_voice, tts_speed: settings.tts_speed, - } + }; + this.hosts_changed(); + this } fn call( @@ -1171,25 +1241,18 @@ impl ChatScreen { /// the lifecycle lock across network round trips; issuing any local /// read after it would queue behind that lock. fn start(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( async move { - let boot = backend.local_bootstrap(&user_id).await?; + let boot = host.bootstrap().await?; let summaries = match &boot.latest { - Some(detail) => { - let store = backend.clone(); - let target = detail.session.id.clone(); - tokio::task::spawn_blocking(move || { - store.load_tool_summaries_blocking(&user_id, &target) - }) + Some(detail) => host + .tool_summaries(detail.session.id.clone()) .await - .map_err(|error| error.to_string())? .unwrap_or_else(|error| { log::warn!("Cannot load tool summaries: {error}"); HashMap::new() - }) - } + }), None => HashMap::new(), }; Ok::<_, String>((boot, summaries)) @@ -1216,24 +1279,29 @@ impl ChatScreen { /// Take what the local bootstrap read from disk. The newest transcript /// opens only while nothing is on screen: a click that landed before - /// this callback wins, and so does a draft the user started while the - /// app was still loading, whose text belongs to the task it creates. + /// this callback wins, so does a draft the user started while the app + /// was still loading, whose text belongs to the task it creates, and a + /// remote host being restored opens its own task. fn apply_bootstrap( &mut self, - boot: crate::backend::LocalBootstrap, + boot: maple_agent::host::HostBootstrap, summaries: HashMap, cx: &mut Context, ) { + self.apply_session_defaults(&boot.session_defaults, cx); self.project_root = boot.project_root; self.project_root_changed(cx); self.check_project_trust(cx); - self.recent_roots = boot.recent_roots; - self.sessions = boot.sessions; - self.sync_sidebar(cx); - if let Some(detail) = boot - .latest - .filter(|_| self.selected_session.is_none() && !self.draft) - { + self.recent_roots = boot.recent_roots.clone(); + if let Some(local) = self.hosts.get_mut(&HostId::local()) { + local.project_root = self.project_root.clone(); + local.recent_roots = boot.recent_roots; + local.session_defaults = Some(boot.session_defaults.clone()); + } + self.apply_host_session_list(&HostId::local(), boot.sessions, cx); + if let Some(detail) = boot.latest.filter(|_| { + self.selected_session.is_none() && !self.draft && self.restore_host.is_none() + }) { let summaries = summaries .into_iter() .map(|(id, summary)| (id, SharedString::from(summary))) @@ -1248,14 +1316,13 @@ impl ChatScreen { /// Phase two of `start`: bring the agent runtime up and fill in what /// needs the network (models, plan, audio, trust of a changed root). fn start_runtime(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let request = backend.default_start_request(); + let host = self.host.clone(); + let request = self.backend.default_start_request(); // A task or project selected while the start is in flight owns the // visible project context. let generation = self.selection_generation; self.call( - async move { backend.start_runtime(&user_id, Some(request)).await }, + async move { host.start_runtime(Some(request)).await }, cx, move |this, result, cx| { match result { @@ -1310,13 +1377,19 @@ impl ChatScreen { ); } + /// Re-read the target host's recent projects. An answer that lands + /// after the target moved on belongs to the previous host and is + /// dropped: the new target's list was requested with it. fn refresh_roots(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); + let target = self.target_host.clone(); self.call( - async move { backend.recent_project_roots(&user_id).await }, + async move { host.recent_project_roots().await }, cx, - |this, result, cx| { + move |this, result, cx| { + if this.target_host != target { + return; + } if let Ok(roots) = result { this.apply_recent_roots(roots, cx); } @@ -1332,9 +1405,20 @@ impl ChatScreen { cx: &mut Context, ) { self.recent_roots = roots.into_iter().map(|root| root.path).collect(); + self.cache_target_context(); self.sync_sidebar(cx); } + /// Remember the visible project context as the target host's, so it + /// is what comes back when that host is the target again. + fn cache_target_context(&mut self) { + let Some(entry) = self.hosts.get_mut(&self.target_host) else { + return; + }; + entry.project_root.clone_from(&self.project_root); + entry.recent_roots.clone_from(&self.recent_roots); + } + /// Select the project context for new tasks without disturbing work that /// is already running in any session. fn select_project_root(&mut self, path: String, cx: &mut Context) { @@ -1349,9 +1433,10 @@ impl ChatScreen { cx.notify(); return; } + // The host validates the path: it owns the filesystem. let path = path.trim().to_string(); - if path.is_empty() || !std::path::Path::new(&path).is_absolute() { - self.notice = Some("Enter an absolute directory path".into()); + if path.is_empty() { + self.notice = Some("Enter a directory path".into()); cx.notify(); return; } @@ -1361,14 +1446,11 @@ impl ChatScreen { // that fails leaves loads in flight alive, while a task clicked after // this point advances the generation and wins over the callback. let selection_generation = self.selection_generation; - self.popup.close(cx); - self.root_input = None; self.notice = None; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.select_project_root(&user_id, path).await }, + async move { host.select_project_root(path).await }, cx, move |this, result, cx| { this.root_selecting = false; @@ -1417,10 +1499,9 @@ impl ChatScreen { /// without changing what is on screen. Used when a task under another /// project is opened and when a project is archived. fn persist_project_root(&mut self, root: String, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.select_project_root(&user_id, root).await }, + async move { host.select_project_root(root).await }, cx, |this, result, cx| match result { Ok(registration) => { @@ -1450,6 +1531,7 @@ impl ChatScreen { return false; } self.project_root = project_root; + self.cache_target_context(); self.project_root_changed(cx); self.check_project_trust(cx); self.refresh_slash_commands(cx); @@ -1457,206 +1539,77 @@ impl ChatScreen { true } - fn choose_root_dialog(&mut self, cx: &mut Context) { - // The platform folder picker through gpui: NSOpenPanel on macOS, - // the common file dialog on Windows, the XDG portal on Linux. - // The panel closes with the app, so quit is never blocked on it. - // Manual entry only when the picker cannot open (e.g. a Linux - // desktop with no portal); a cancel just closes. - if !self.begin_root_picker(cx) { - return; - } - let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: None, - }); - let bridge = cx.spawn(async move |this, cx| { - let picked = receiver.await; - this.update(cx, |this, cx| { - this.root_picker_open = false; - match picked { - Ok(Ok(Some(paths))) => { - if let Some(path) = paths.into_iter().next() { - this.select_project_root(path.to_string_lossy().into_owned(), cx); - } - } - // Cancelled, or the picker dropped its channel. - Ok(Ok(None)) | Err(_) => {} - Ok(Err(_)) => this.show_root_input(cx), - } - cx.notify(); - }) - .ok(); - }); - // The portal dialog completes on its own thread; retained so the - // bridge dies here (see ChatScreen::call). - crate::ui::task::retain(&self.bridged_tasks, bridge); - } - - /// The root changed: update the header label, then read its branch - /// and watch its git dir. + /// The root changed: update the header label, then ask the host for + /// its branch. fn project_root_changed(&mut self, cx: &mut Context) { self.project_label = SharedString::from(self.project_label()); self.refresh_branch(cx); } - /// Read the branch for the current root, or clear it when there is - /// no root. The read also resolves the git dir, and the watcher is - /// replaced when that dir changed. + /// Follow the branch of the current root, or clear it when there is + /// no root. The host owns the git dir watch and reports the branch + /// through [`HostEvent::ProjectBranch`]; this screen only tells it + /// which root to follow. fn refresh_branch(&mut self, cx: &mut Context) { - if self.project_root.is_some() { - self.read_branch(cx); + if self.watched_root == self.project_root { return; } - self.branch_watcher = None; - self.watched_git_dir = None; - self.set_branch(None, cx); - } - - fn set_branch(&mut self, branch: Option, cx: &mut Context) { - if self.project_branch == branch { - return; + let host = self.host.clone(); + let previous = self.watched_root.take(); + let next = self.project_root.clone(); + self.watched_root = next.clone(); + if next.is_none() { + self.set_branch(None, cx); } - self.branch_label = branch - .as_deref() - .map(|branch| SharedString::from(format!("({branch})"))); - self.project_branch = branch; - cx.notify(); - } - - /// Watch `git_dir` and re-read the branch when `HEAD` changes. The - /// watch is on the directory, not the file: git replaces `HEAD` by - /// rename, so a watch on the file itself is lost after the first - /// checkout. Non-recursive, so a busy `objects/` tree costs nothing. - /// Events arrive on the watcher's own thread and cross to the UI - /// through a channel, like backend events. Dropping the watcher - /// closes the channel, which ends the receiver task. - fn watch_branch(&mut self, git_dir: &std::path::Path, cx: &mut Context) { - use notify::Watcher as _; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut watcher = - match notify::recommended_watcher(move |event: notify::Result| { - let Ok(event) = event else { return }; - if head_change_event(&event) { - tx.send(()).ok(); + self.call( + async move { + if let Some(previous) = previous { + host.unwatch_project_root(previous).await?; } - }) { - Ok(watcher) => watcher, - Err(error) => { - log::debug!("branch watcher unavailable: {error}"); - return; + if let Some(next) = next { + host.watch_project_root(next).await?; } - }; - if let Err(error) = watcher.watch(git_dir, notify::RecursiveMode::NonRecursive) { - log::debug!("cannot watch {}: {error}", git_dir.display()); - return; - } - self.branch_watcher = Some(watcher); - cx.spawn(async move |this, cx| { - while rx.recv().await.is_some() { - // A rebase or a checkout touches HEAD several times in a - // row; one read per burst is enough. - while rx.try_recv().is_ok() {} - if this.update(cx, |this, cx| this.read_branch(cx)).is_err() { - break; + Ok(()) + }, + cx, + |_this, result: Result<(), String>, _cx| { + if let Err(message) = result { + log::debug!("branch watch unavailable: {message}"); } - } - }) - .detach(); + }, + ); } - /// Read the branch for the current root off the UI thread. The git - /// dir comes back with it so the watcher follows a root change without - /// a file stat on the UI thread. - fn read_branch(&mut self, cx: &mut Context) { - let Some(root) = self.project_root.clone() else { + /// Ask the host to report the current root's branch again. Re-watching + /// a root the host already watches re-sends its branch. + fn reread_branch(&self, cx: &mut Context) { + let Some(root) = self.watched_root.clone() else { return; }; + let host = self.host.clone(); self.call( async move { - tokio::task::spawn_blocking(move || { - let git_dir = git_dir(std::path::Path::new(&root)); - let branch = git_dir.as_deref().and_then(git_branch); - Ok((root, git_dir, branch)) - }) - .await - .map_err(|error| format!("Branch lookup failed: {error}"))? + host.watch_project_root(root.clone()).await?; + host.unwatch_project_root(root).await }, cx, - |this, result, cx| { - // Drop a late answer for a root that is no longer current. - let Ok((root, git_dir, branch)) = result else { - return; - }; - if this.project_root.as_deref() != Some(root.as_str()) { - return; - } - if this.watched_git_dir != git_dir { - this.branch_watcher = None; - if let Some(dir) = &git_dir { - this.watch_branch(dir, cx); - } - this.watched_git_dir = git_dir; - } - this.set_branch(branch, cx); - }, + |_this, _result: Result<(), String>, _cx| {}, ); } - /// Claim the folder picker. One at a time: several at once each - /// applied their own result and stalled the app. Returns `false` when - /// a picker or a project selection is already in progress. - fn begin_root_picker(&mut self, cx: &mut Context) -> bool { - if self.root_picker_open || self.root_selecting { - return false; + fn set_branch(&mut self, branch: Option, cx: &mut Context) { + if self.project_branch == branch { + return; } - self.root_picker_open = true; - self.popup.close(cx); + self.branch_label = branch + .as_deref() + .map(|branch| SharedString::from(format!("({branch})"))); + self.project_branch = branch; cx.notify(); - true - } - - /// Manual path entry when the native picker is unavailable. - fn show_root_input(&mut self, cx: &mut Context) { - // The native picker could not open: offer manual entry. - if self.root_input.is_none() { - let chat = cx.entity().downgrade(); - let apply = chat.clone(); - let application_vim_enabled = self.application_vim_enabled; - let input = cx.new(move |cx| { - TextInput::new("/absolute/path/to/project", cx) - .with_tab_index(0) - .application_vim(application_vim_enabled) - .on_application_escape(move |window, cx| { - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| chat.focus_application_vim(window, cx)); - } - }) - // Enter applies the typed path, like the Go button. - .on_enter(move |path, window, cx| { - let apply = apply.clone(); - window.defer(cx, move |_, cx| { - apply - .update(cx, |chat, cx| chat.select_project_root(path, cx)) - .ok(); - }); - }) - }); - self.root_input = Some(input); - } - self.popup.open(ChatPopup::Project, cx); - self.root_input_focus_pending = true; } - /// Open or close the project menu from the header chip. - pub fn toggle_root_menu(&mut self, cx: &mut Context) { - self.popup.toggle(ChatPopup::Project, cx); - } - - /// Open or close one of the composer chips' menus. Opening the - /// integrations menu refreshes the task's servers. + /// Open or close one of the chip menus. Opening the integrations menu + /// refreshes the task's servers. pub(super) fn toggle_popup(&mut self, popup: ChatPopup, cx: &mut Context) { if self.popup.toggle(popup, cx) && popup == ChatPopup::Integrations { self.refresh_session_mcp(cx); @@ -1672,13 +1625,16 @@ impl ChatScreen { } fn refresh_models(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let env_model = backend.configured_model(); + let host = self.host.clone(); + let env_model = self.backend.configured_model(); self.call( async move { - let saved = backend.saved_model(&user_id).await; - let models = backend.available_model_ids(&user_id).await?; + let saved = host + .session_defaults() + .await + .ok() + .and_then(|defaults| defaults.default_model); + let models = host.available_model_ids().await?; Ok((models, saved)) }, cx, @@ -1698,18 +1654,33 @@ impl ChatScreen { ); } - fn refresh_sessions(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - // The sidebar groups tasks by project, so list every root. Each task's - // stored root remains authoritative when it is opened or run. - let generation = self.selection_generation; + /// The sidebar opened a project's menu and asks whether the project is + /// trusted, on the target host; the answer goes back to the menu. + fn answer_project_trust(&mut self, root: String, cx: &mut Context) { + let host = self.host.clone(); self.call( - async move { backend.list_sessions(&user_id, None).await }, + async move { host.project_trust(root).await }, cx, - move |this, result, cx| { + |this, result, cx| { + if let Ok(status) = result { + this.sidebar + .update(cx, |sidebar, cx| sidebar.set_menu_trust(status, cx)); + } + }, + ); + } + + /// Rename a task on the host that owns it. + fn rename_session(&mut self, session_id: String, name: String, cx: &mut Context) { + let host = self.backend_for(&session_id); + self.call( + async move { host.rename_session(session_id, name).await }, + cx, + |this, result, cx| { match result { - Ok(sessions) => this.apply_session_list(sessions, generation, cx), + Ok(session) => { + this.upsert_session(session, cx); + } Err(message) => this.notice = Some(message.into()), } cx.notify(); @@ -1718,35 +1689,39 @@ impl ChatScreen { } /// Take a fresh session list. With nothing on screen, open the latest - /// task of the visible project or show the new-task screen, but only - /// when no task or project was selected since the list was requested: - /// a click whose load is still in flight leaves the selection empty - /// too, and the auto-select would supersede it. A draft is not - /// "nothing on screen": the list that lands after "New Task" (the boot - /// refresh, a project switch) must not open a task under the text - /// being typed. + /// task of the visible project or create one, but only when no task or + /// project was selected since the list was requested: a click whose + /// load is still in flight leaves the selection empty too, and the + /// auto-select would supersede it. A draft is not "nothing on screen": + /// the list that lands after "New Task" (the boot refresh, a project + /// or host switch) must not open a task under the text being typed. fn apply_session_list( &mut self, sessions: Vec, generation: u64, cx: &mut Context, ) { - self.sessions = sessions; - self.sync_sidebar(cx); - if self.selected_session.is_some() || self.draft || self.selection_generation != generation + self.apply_host_session_list(&HostId::local(), sessions, cx); + if self.selected_session.is_some() + || self.draft + || self.selection_generation != generation + || self.restore_host.is_some() { return; } let root = self.project_root.clone(); + // The visible project belongs to the target host; another host's + // task under the same path is not it. let latest = self .sessions .iter() .find(|session| { // An empty task (a draft an older build persisted) is not - // worth opening; the new-task screen is the same thing. + // worth opening; the draft screen is the same thing. session.state != AgentTaskState::Archived && session.message_count > 0 && Some(&session.project_root) == root.as_ref() + && self.host_of(&session.id) == self.target_host }) .map(|session| session.id.clone()); match latest { @@ -1755,10 +1730,26 @@ impl ChatScreen { } } - /// "New Task": show the empty screen for the visible project and - /// create nothing. The task is created the moment the first message - /// is sent, so a project switched before then moves it, and a click - /// that sends nothing leaves no empty row behind. + /// "New Task": show the empty screen for the target host's project and + /// create nothing. The task is created on the target the moment the + /// first message is sent, so a host or project switched before then + /// moves it, and a click that sends nothing leaves no empty row on + /// any host. + /// A selected task nothing has happened to: no messages, no run, no + /// load in flight. Older builds persisted one on every New Task + /// click, so such tasks still exist and can be opened. The screen + /// treats one like the draft it is. + pub(super) fn selection_is_draft(&self) -> bool { + match self.selected_session.as_deref() { + Some(selected) => { + self.timeline.is_empty() + && self.loading_session.is_none() + && !self.active_runs.contains_key(selected) + } + None => false, + } + } + pub(super) fn new_session(&mut self, cx: &mut Context) { // The first send is creating its task; a click now would // supersede it and lose the message. @@ -1780,18 +1771,18 @@ impl ChatScreen { self.loading_session = None; self.clear_selected_session_presentation(cx); self.draft = true; - // The draft starts from the settings defaults; the chips edit it - // on screen only, until the task exists. + // The draft starts from the target host's defaults; the chips edit + // it on screen only, until the task exists. self.web_enabled = self.default_web_enabled; self.refresh_draft_mcp(cx); self.refresh_selected_title(); cx.notify(); } - /// Create the task the draft describes, then run `action` against it. - /// The text stays in the composer, which shows the create in - /// progress, until the send goes out; a create that does not happen - /// leaves it there. + /// Create the task the draft describes on the target host, then run + /// `action` against it. The text stays in the composer, which shows + /// the create in progress, until the send goes out; a create that + /// does not happen leaves it there. fn create_for_first_send(&mut self, action: FirstSend, cx: &mut Context) { self.slash_selected = None; // A command path cleared the composer before it got here; put the @@ -1879,9 +1870,9 @@ impl ChatScreen { self.send_text_with(text, action.steer(), cx); } - /// Create a task from `request`. What the composer shows and the - /// request cannot carry is applied once the task exists, before the - /// first send (`settle_new_session`). + /// Create a task on the target host from `request`. What the composer + /// shows and the request cannot carry is applied once the task + /// exists, before the first send (`settle_new_session`). fn create_session(&mut self, request: AgentCreateSessionRequest, cx: &mut Context) { // Creating a task is a navigation intent, but the generation moves // only when the task lands: a failed create leaves loads in flight @@ -1889,19 +1880,22 @@ impl ChatScreen { // eventual callback. let selection_generation = self.selection_generation; self.session_setup_pending = true; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); + let owner = self.target_host.clone(); let requested_mcp = request.mcp_server_names.clone(); self.call( async move { - backend - .create_session(&user_id, Some(request)) + host.create_session(Some(request)) .await .map(|detail| detail.session) }, cx, move |this, result, cx| match result { Ok(session) => { + // The task lives on the host the create ran on, whatever + // host new tasks target by now; calls about it go there. + this.session_hosts.insert(session.id.clone(), owner); + this.hosts_dirty = true; let task_mcp = this.created_task_mcp(requested_mcp.as_deref()); this.settle_new_session(session, task_mcp, selection_generation, cx); } @@ -1993,39 +1987,34 @@ impl ChatScreen { self.finish_new_session(session, selection_generation, cx); return; } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + // The settings go to the host the task was created on. + let host = self.backend_for(&session.id); let fallback = session.clone(); let applied = changes.mcp.clone(); self.call( async move { let mut session = session; if let Some(enabled) = changes.web { - session = backend - .set_session_web_enabled(&user_id, &session.id, enabled) + session = host + .set_session_web_enabled(session.id.clone(), enabled) .await .map_err(|message| format!("Could not change web access: {message}"))?; } if let Some(mode) = changes.mode { - backend - .set_permission_mode(&user_id, &session.id, &mode) + host.set_permission_mode(session.id.clone(), mode.clone()) .await .map_err(|message| format!("Could not set permission mode: {message}"))?; session.mode = mode; } for change in &changes.mcp { - backend - .set_session_mcp_server_enabled( - &user_id, - &session.id, - &change.name, - change.kind, - change.enabled, - ) - .await - .map_err(|message| { - format!("Could not change {}: {message}", change.name) - })?; + host.set_session_mcp_server_enabled( + session.id.clone(), + change.name.clone(), + change.kind, + change.enabled, + ) + .await + .map_err(|message| format!("Could not change {}: {message}", change.name))?; } Ok(session) }, @@ -2078,6 +2067,7 @@ impl ChatScreen { return; } self.session_setup_pending = false; + self.remember_task_host(&session.id); self.upsert_session(session.clone(), cx); self.begin_navigation(); self.set_active_session(session, Vec::new(), HashMap::new(), cx); @@ -2101,6 +2091,7 @@ impl ChatScreen { return; } self.session_setup_pending = false; + self.remember_task_host(&session.id); // The SessionCreated event may arrive before this callback; upsert // so the sidebar never shows the task twice. self.upsert_session(session.clone(), cx); @@ -2111,6 +2102,16 @@ impl ChatScreen { } } + /// A new task is opening on the host that owns it: the next launch + /// starts there too. An abandoned task is deleted, not remembered. + fn remember_task_host(&self, session_id: &str) { + let owner = self.host_of(session_id); + let last_task_host = (!owner.is_local()).then(|| owner.to_string()); + crate::settings::update_settings_in_background(move |settings| { + settings.last_task_host = last_task_host; + }); + } + /// The task landed after the user left the draft: a task or project /// was opened meanwhile, so the message has no screen to go to and /// is not sent. It goes back to the composer when that is empty and @@ -2146,12 +2147,13 @@ impl ChatScreen { // The SessionCreated event may have listed it already; one row // until the delete confirms. self.upsert_session(session.clone(), cx); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + // The delete goes to the host that owns the task, whatever host + // new tasks target by now. + let host = self.backend_for(&session.id); let session_id = session.id; let deleted = session_id.clone(); self.call( - async move { backend.delete_session(&user_id, &session_id).await }, + async move { host.delete_session(session_id).await }, cx, move |this, result, cx| { match result { @@ -2178,8 +2180,8 @@ impl ChatScreen { // summary would reset the chip to Ask First. mode: Some(self.permission_mode.as_str().to_string()), // The draft's switches decide which servers start with the - // task; the runtime reads curated MCP integrations (CUA) from - // this list too. External agents are not servers and are + // task; the host's runtime reads curated MCP integrations (CUA) + // from this list too. External agents are not servers and are // applied after creation. mcp_server_names: self.draft_mcp_names(), system_prompt: None, @@ -2231,28 +2233,25 @@ impl ChatScreen { self.loading_session = Some(session_id.to_string()); cx.notify(); } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(session_id); + // The host answering the load owns the task; a mapping dropped + // while the load is in flight (a connection blip) comes back as + // this, not as whatever host new tasks target by then. + let owner = self.host_of(session_id); let session_id = session_id.to_string(); let target = session_id.clone(); self.call( async move { - // The stored summaries load off-thread while the runtime - // builds the session detail. - let store = backend.clone(); - let store_user = user_id.clone(); - let store_target = target.clone(); - let summaries = tokio::task::spawn_blocking(move || { - store.load_tool_summaries_blocking(&store_user, &store_target) + // The stored summaries load while the runtime builds the + // session detail. + let (detail, summaries) = tokio::join!( + host.load_session(target.clone()), + host.tool_summaries(target.clone()) + ); + let summaries = summaries.unwrap_or_else(|error| { + log::warn!("Cannot load tool summaries: {error}"); + HashMap::new() }); - let (detail, summaries) = - tokio::join!(backend.load_session(&user_id, &target), summaries); - let summaries = summaries - .map_err(|error| error.to_string())? - .unwrap_or_else(|error| { - log::warn!("Cannot load tool summaries: {error}"); - HashMap::new() - }); Ok::<_, String>((detail?, summaries)) }, cx, @@ -2295,6 +2294,7 @@ impl ChatScreen { .collect(); match mode { LoadMode::Select => { + this.file_session(&detail.session.id, &owner); this.upsert_session(detail.session.clone(), cx); this.set_active_session(detail.session, detail.timeline, summaries, cx); this.queue = detail.queue.items; @@ -2383,21 +2383,55 @@ impl ChatScreen { self.refresh_selected_title(); } - /// Apply settings-default changes when returning from the settings - /// screen: tool verbosity updates live; the permission default only - /// affects sessions that still follow the default. + /// Apply the host's session defaults: web access for new tasks, and + /// the permission mode for sessions that still follow the default. + fn apply_session_defaults(&mut self, defaults: &HostSessionDefaults, cx: &mut Context) { + if let Some(entry) = self.hosts.get_mut(&self.target_host) { + entry.session_defaults = Some(defaults.clone()); + } + self.default_web_enabled = defaults.web_enabled; + if self.selected_session.is_none() { + // The draft has no record of its own; the chip shows the + // default the task will be created with. + self.web_enabled = defaults.web_enabled; + } + if self.uses_default_permission_mode { + let mode = PermissionMode::parse(&defaults.permission_mode); + if mode != self.permission_mode { + self.permission_mode = mode; + self.apply_permission_mode(cx); + } + } + } + + /// Re-read the host's session defaults, after the settings screen + /// may have changed them. + fn refresh_session_defaults(&self, cx: &mut Context) { + let host = self.host.clone(); + self.call( + async move { host.session_defaults().await }, + cx, + |this, result, cx| match result { + Ok(defaults) => { + this.apply_session_defaults(&defaults, cx); + cx.notify(); + } + Err(message) => log::debug!("session defaults unavailable: {message}"), + }, + ); + } + + /// Apply settings changes when returning from the settings screen: + /// tool verbosity updates live; the host's session defaults are + /// re-read, and the permission default only affects sessions that + /// still follow the default. pub fn apply_defaults( &mut self, settings: &crate::settings::AppSettings, cx: &mut Context, ) { self.tool_details = settings.tool_details; - self.default_web_enabled = settings.default_web_enabled; - if self.selected_session.is_none() { - // The draft has no record of its own; the chip shows the - // default the task will be created with. - self.web_enabled = settings.default_web_enabled; - } + self.refresh_session_defaults(cx); self.notify_enabled = settings.desktop_notifications; self.summaries_enabled = settings.tool_summaries; self.composer_vim_enabled = settings.composer_vim_enabled; @@ -2421,10 +2455,6 @@ impl ChatScreen { self.screen_focus_pending = true; self.tts_voice.clone_from(&settings.tts_voice); self.tts_speed = settings.tts_speed; - if self.uses_default_permission_mode { - self.permission_mode = settings.default_permission_mode; - self.apply_permission_mode(cx); - } // Servers may have been added or removed in settings. self.refresh_session_mcp(cx); cx.notify(); @@ -2434,19 +2464,14 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let model = self.selected_model.clone(); self.call( - async move { - backend - .context_usage(&user_id, &session_id, model.as_deref()) - .await - }, + async move { host.context_usage(session_id, model).await }, cx, |this, result, cx| { - if let Ok(Some((tokens, limit))) = result { - this.apply_context_usage(tokens, limit, cx); + if let Ok(Some(usage)) = result { + this.apply_context_usage(usage.tokens, usage.limit, cx); } }, ); @@ -2459,12 +2484,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let requested = session_id.clone(); let epoch = self.subagent_epoch; self.call( - async move { backend.session_subagents(&user_id, &requested).await }, + async move { host.session_subagents(requested.clone()).await }, cx, move |this, result, cx| { let Ok(subagents) = result else { @@ -2597,13 +2621,12 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let compacted = session_id.clone(); self.notice = Some("Compacting…".into()); cx.notify(); self.call( - async move { backend.compact_session(&user_id, &session_id).await }, + async move { host.compact_session(session_id.clone()).await }, cx, move |this, result, cx| match result { Ok(()) => { @@ -2628,13 +2651,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let mode = self.permission_mode.as_str().to_string(); self.call( async move { - backend - .set_permission_mode(&user_id, &session_id, &mode) + host.set_permission_mode(session_id.clone(), mode.clone()) .await }, cx, @@ -2730,8 +2751,14 @@ impl ChatScreen { // marker goes. Only an explicit settle ever moves a task out of // the active inbox. self.completed_unread_sessions.remove(&session.id); - self.selected_session = Some(session.id); + self.selected_session = Some(session.id.clone()); self.draft = false; + // The selected task's host becomes the target for new tasks unless + // the sidebar filters on a host. + let owner = self.host_of(&session.id); + if self.host_filter.is_none() && owner != self.target_host { + self.set_target_host(owner, cx); + } self.sync_sidebar(cx); let previous_root = self.project_root.clone(); if self.set_project_context(Some(project_root.clone()), cx) && previous_root.is_some() { @@ -2800,11 +2827,10 @@ impl ChatScreen { if self.model_vision.contains_key(&model) { return; } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let lookup = model.clone(); self.call( - async move { backend.model_supports_vision(&user_id, &lookup).await }, + async move { host.model_supports_vision(lookup.clone()).await }, cx, move |this, result, cx| { if let Ok(Some(vision)) = result { @@ -2824,12 +2850,11 @@ impl ChatScreen { } /// Reload the skill slash commands for the current project root. pub fn refresh_slash_commands(&mut self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let working_dir = self.project_root.clone(); let requested_root = working_dir.clone(); self.call( - async move { backend.list_slash_commands(&user_id, working_dir).await }, + async move { host.list_slash_commands(working_dir).await }, cx, move |this, result, cx| { if this.project_root == requested_root @@ -2857,11 +2882,10 @@ impl ChatScreen { } return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let target = session_id.clone(); self.call( - async move { backend.list_session_mcp_servers(&user_id, &target).await }, + async move { host.list_session_mcp_servers(target.clone()).await }, cx, move |this, result, cx| { if this.selected_session.as_deref() != Some(session_id.as_str()) { @@ -2876,20 +2900,18 @@ impl ChatScreen { ); } - /// Load the integrations a new task would start with, for the draft's - /// chip: the configured MCP servers with their defaults and the - /// curated integrations (external agents, CUA), so they can be - /// switched on for the first turn. The rows on screen stay until the - /// fresh ones land, and the user's toggles carry over. + /// Load the integrations a task created on the target host would start + /// with, for the draft's chip: the host's configured MCP servers with + /// their defaults and its curated integrations (external agents, CUA), + /// so they can be switched on for the first turn. The rows on screen + /// stay until the fresh ones land, and the user's toggles carry over. fn refresh_draft_mcp(&mut self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); + let target = self.target_host.clone(); self.call( async move { - let (servers, integrations) = tokio::join!( - backend.list_mcp_servers(&user_id), - backend.list_integrations(&user_id) - ); + let (servers, integrations) = + tokio::join!(host.list_mcp_servers(), host.list_integrations()); // The configured servers are the chip's floor; a catalog // that cannot be read costs only the curated rows. let integrations = integrations.unwrap_or_else(|message| { @@ -2900,7 +2922,7 @@ impl ChatScreen { }, cx, move |this, result, cx| { - if !this.draft { + if !this.draft || this.target_host != target { return; } match result { @@ -2981,13 +3003,11 @@ impl ChatScreen { cx.notify(); return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let target = session_id.clone(); self.call( async move { - backend - .set_session_mcp_server_enabled(&user_id, &target, &name, kind, enabled) + host.set_session_mcp_server_enabled(target.clone(), name.clone(), kind, enabled) .await }, cx, @@ -3016,15 +3036,10 @@ impl ChatScreen { let previous = self.web_enabled; self.web_enabled = enabled; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let target = session_id.clone(); self.call( - async move { - backend - .set_session_web_enabled(&user_id, &target, enabled) - .await - }, + async move { host.set_session_web_enabled(target.clone(), enabled).await }, cx, move |this, result, cx| { match result { @@ -3206,13 +3221,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let agent_id = agent_id.to_string(); self.call( async move { - backend - .cancel_external_agent(&user_id, &session_id, &agent_id) + host.cancel_external_agent(session_id.clone(), agent_id.clone()) .await }, cx, @@ -3413,6 +3426,10 @@ impl ChatScreen { } fn escape(&mut self, cx: &mut Context) { + if self.project_picker.is_some() { + self.close_project_picker(cx); + return; + } // A rename ends first. A project's rename field sits in the // switcher, which passes Escape on and stays open. if self @@ -3521,13 +3538,18 @@ impl ChatScreen { cx.stop_propagation(); return; } - // A modal dialog owns the keyboard; nothing types past it. + // A modal dialog owns the keyboard; nothing types past it. The + // project picker's search box takes what its own input misses. if self.trust_prompt.is_some() || self.confirm.is_some() { return; } + if self.project_picker.is_some() { + self.root_input_focus_pending = true; + cx.notify(); + return; + } // A focused text field already receives typing: the composer, the - // question card, the sidebar's search or rename, or the path field - // in the project menu. + // question card, or the sidebar's search or rename. if window .context_stack() .iter() @@ -3678,9 +3700,9 @@ impl ChatScreen { cx.notify(); return; } - // With no task the first send creates one and runs there. One - // create at a time: while it is in flight the composer shows it - // and keeps the text; Enter changes nothing. + // With no task the first send creates one on the target host and + // runs there. One create at a time: while it is in flight the + // composer shows it and keeps the text; Enter changes nothing. let session_id = self.selected_session.clone(); if session_id.is_none() && self.session_setup_pending { self.notice = Some("The task is still being created".into()); @@ -3947,7 +3969,7 @@ impl ChatScreen { let Some(session_id) = session_id else { return true; }; - let backend = self.backend.clone(); + let host = self.host.clone(); let working_dir = self.project_root.clone(); let command = name.to_string(); let arguments = args.to_string(); @@ -3955,8 +3977,7 @@ impl ChatScreen { cx.notify(); self.call( async move { - backend - .resolve_slash_command(working_dir, command, arguments) + host.resolve_slash_command(working_dir, command, arguments) .await }, cx, @@ -3992,8 +4013,7 @@ impl ChatScreen { cx: &mut Context, ) { let session_id = session_id.to_string(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let model = self.selected_model.clone(); let vision_capable = self.selected_model_supports_vision(); let run_active = self.active_runs.contains_key(&session_id); @@ -4049,7 +4069,7 @@ impl ChatScreen { } cx.notify(); self.call( - async move { backend.send_message(&user_id, request).await }, + async move { host.send_message(request).await }, cx, move |this, result, cx| match result { Ok(run_id) => { @@ -4116,10 +4136,9 @@ impl ChatScreen { let Some(run_id) = self.active_runs.get(&session_id).cloned() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); self.call( - async move { backend.cancel_run(&user_id, &run_id).await }, + async move { host.cancel_run(run_id.clone()).await }, cx, |this, result, cx| { if let Err(message) = result { @@ -4237,8 +4256,7 @@ impl ChatScreen { } let request_id = question.request_id.clone(); let callback_request_id = request_id.clone(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); // Drop the answered question and its input so the next card starts // fresh; a queued question's event already fired, so the input is // recreated right away when one is showing. @@ -4247,7 +4265,7 @@ impl ChatScreen { self.reset_question_card(cx); cx.notify(); self.call( - async move { backend.answer_question(&user_id, &request_id, answer).await }, + async move { host.answer_question(request_id.clone(), answer).await }, cx, move |this, result, cx| match result { Ok(true) => {} @@ -4280,25 +4298,19 @@ impl ChatScreen { .retain(|queued| queued.request_id != question.request_id); self.reset_question_card(cx); cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); { let request_id = question.request_id.clone(); - let answer_backend = backend.clone(); - let answer_user = user_id.clone(); + let answer_host = host.clone(); self.call( - async move { - answer_backend - .answer_question(&answer_user, &request_id, String::new()) - .await - }, + async move { answer_host.answer_question(request_id, String::new()).await }, cx, |_this, _result, _cx| {}, ); } if let Some(run_id) = self.active_runs.get(&question.session_id).cloned() { self.call( - async move { backend.cancel_run(&user_id, &run_id).await }, + async move { host.cancel_run(run_id).await }, cx, |this, result, cx| { if let Err(message) = result { @@ -4402,19 +4414,16 @@ impl ChatScreen { } self.permission_responding = true; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let request_id = permission.request_id.clone(); self.call( async move { - backend - .permission_respond( - &user_id, - &permission.session_id, - &permission.request_id, - allow, - ) - .await + host.permission_respond( + permission.session_id.clone(), + permission.request_id.clone(), + allow, + ) + .await }, cx, move |this, result, cx| { @@ -4441,10 +4450,22 @@ impl ChatScreen { self.audio.cancel_recording(); } let backend = self.backend.clone(); + // The account signs out of this machine: its runtime stops, whatever + // host new tasks target. Remote runtimes belong to their hosts. + let local = self + .hosts + .get(&HostId::local()) + .and_then(|entry| entry.backend.clone()); let user_id = self.user_id.clone(); self.call( async move { - backend.stop_runtime(&user_id).await?; + if let Some(local) = local + && let Err(error) = local.stop_runtime().await + { + // Signing out must not hang on a runtime that will not + // stop; the logout clears the account either way. + log::warn!("Cannot stop the local runtime: {error}"); + } backend.logout_and_clear(&user_id).await }, cx, @@ -4460,10 +4481,9 @@ impl ChatScreen { cx.notify(); self.refresh_vision(cx); // Remember the choice across launches via the agent config. - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.save_default_model(&user_id, model).await }, + async move { host.save_default_model(model).await }, cx, |_this, _result, _cx| {}, ); @@ -4559,6 +4579,10 @@ impl ChatScreen { if session.state == AgentTaskState::Archived { self.completed_unread_sessions.remove(&session.id); } + // A task with no host yet was made on the target; one an event + // announced was filed under the event's host before this. + let target = self.target_host.clone(); + self.file_session(&session.id, &target); if let Some(existing) = self .sessions .iter_mut() @@ -4650,56 +4674,108 @@ impl ChatScreen { .find(|permission| permission.session_id == selected) } - pub fn handle_service_events( - &mut self, - events: Vec, - cx: &mut Context, - ) { + /// Apply a batch of the local host's events in one update. + pub fn handle_host_events(&mut self, events: Vec, cx: &mut Context) { + self.apply_host_events(&HostId::local(), events, cx); + } + + /// Apply what `host` reported. + fn apply_host_events(&mut self, host: &HostId, events: Vec, cx: &mut Context) { let mut changed = false; for event in events { - changed |= self.apply_service_event(event, cx); + changed |= match event { + HostEvent::Service(event) => self.apply_service_event(host, *event, cx), + HostEvent::ProjectBranch { + project_root, + branch, + } => self.apply_project_branch(&project_root, branch, cx), + HostEvent::Resync => { + // Events may have been lost: re-read the list and the + // task on screen instead of trusting what arrived. + self.refresh_sessions(cx); + if let Some(session_id) = self.selected_session.clone() { + self.reload_timeline(&session_id, cx); + } + false + } + }; } if changed { cx.notify(); } } + /// The host reported a branch. Only the current root's matters; a + /// late report for a root that is no longer current is dropped. + fn apply_project_branch( + &mut self, + project_root: &str, + branch: Option, + cx: &mut Context, + ) -> bool { + if self.project_root.as_deref() != Some(project_root) { + return false; + } + let changed = self.project_branch != branch; + self.set_branch(branch, cx); + changed + } + /// Route one backend service event into UI state. #[cfg(test)] pub fn handle_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) { - if self.apply_service_event(event, cx) { + if self.apply_service_event(&HostId::local(), event, cx) { cx.notify(); } } - /// Apply one event; returns false when nothing visible changed. - fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { + /// Apply one event from `host`; returns false when nothing visible + /// changed. + fn apply_service_event( + &mut self, + host: &HostId, + event: AgentServiceEvent, + cx: &mut Context, + ) -> bool { match event { AgentServiceEvent::RuntimeStatus(mut status) => { // A runtime that just started points Goose at the account's // skills, which the first scan ran before; ask again. - if status.running && !self.runtime_running_seen { + if host.is_local() && status.running && !self.runtime_running_seen { self.runtime_running_seen = true; self.refresh_slash_commands(cx); } - // The status snapshot is authoritative for active runs; one - // that repeats the known state changes nothing. A snapshot - // raced with a terminal event must not resurrect that run. + // The status snapshot is authoritative for its host's active + // runs and says nothing about other hosts'. One that repeats + // the known state changes nothing. A snapshot raced with a + // terminal event must not resurrect that run. status .active_runs .retain(|_, run_id| !self.finished_runs.contains(run_id)); - if self.active_runs == status.active_runs { + let mut merged: HashMap = self + .active_runs + .iter() + .filter(|(session_id, _)| self.host_of(session_id) != *host) + .map(|(session_id, run_id)| (session_id.clone(), run_id.clone())) + .collect(); + for (session_id, run_id) in status.active_runs { + self.file_session(&session_id, host); + merged.insert(session_id, run_id); + } + if self.active_runs == merged { return false; } - self.active_runs = status.active_runs; + self.active_runs = merged; // Run membership decides the inbox sections: a task woken // from elsewhere moves the moment the snapshot lands. self.sync_sidebar(cx); } AgentServiceEvent::SessionCreated(session) => { + self.file_session(&session.id, host); return self.upsert_session(session, cx); } AgentServiceEvent::SessionUpdated { session, .. } => { + self.file_session(&session.id, host); return self.upsert_session(session, cx); } AgentServiceEvent::TimelineItem { @@ -4750,7 +4826,10 @@ impl ChatScreen { session_id, run_id, event, - } => return self.handle_run_event(&session_id, &run_id, event, cx), + } => { + self.file_session(&session_id, host); + return self.handle_run_event(&session_id, &run_id, event, cx); + } AgentServiceEvent::SideQuestion { request_id, event, .. } => { @@ -4966,9 +5045,9 @@ impl ChatScreen { self.subagent_epoch += 1; } } - // The watcher covers checkouts; this catches a + // The host's watcher covers checkouts; this catches a // change that landed between two events. - self.read_branch(cx); + self.reread_branch(cx); // The run that asked is gone (stopped or failed): its // questions would block the composer forever. self.clear_session_questions(session_id, cx); @@ -5270,6 +5349,9 @@ impl Render for ChatScreen { .child(main), ), ) + .when(self.project_picker.is_some(), |root| { + root.child(self.render_project_picker(cx)) + }) .when_some(self.lightbox.clone(), |root, image| { root.child(motion::fade_in( div() @@ -5441,25 +5523,41 @@ impl ChatScreen { self.session_mcp = servers; } - /// Cache the header title for the selected task. /// Cache the header title for the selected task. The header names what /// the pane shows: with no transcript on screen it is a new task, - /// whatever the list has selected. + /// whatever the list has selected. A task whose host dropped leaves + /// the list but stays on screen, and keeps the title it had. fn refresh_selected_title(&mut self) { + self.refresh_selected_host(); if self.timeline.is_empty() && self.loading_session.is_none() { self.selected_title = DEFAULT_TASK_TITLE.into(); return; } - self.selected_title = self - .selected_session - .as_deref() - .and_then(|selected| { - self.sessions - .iter() - .position(|session| session.id == selected) - }) - .map(|index| SharedString::from(self.sessions[index].title.clone())) - .unwrap_or_else(|| DEFAULT_TASK_TITLE.into()); + let Some(selected) = self.selected_session.as_deref() else { + self.selected_title = DEFAULT_TASK_TITLE.into(); + return; + }; + if let Some(session) = self.sessions.iter().find(|session| session.id == selected) { + self.selected_title = SharedString::from(session.title.clone()); + } + } + + /// Cache the host the task on screen belongs to. The header names it + /// while a task is open, whatever host new tasks target, so the chip + /// never implies a local task runs on the remote host it was switched + /// to. The new-task screen has no task, so it shows the target chip. + fn refresh_selected_host(&mut self) { + if self.selection_is_draft() { + // Nothing has happened to the task on screen: the header + // offers the target chip, as on the new-task screen. + self.selected_host = None; + return; + } + self.selected_host = self.selected_session.as_deref().map(|selected| { + let owner = self.host_of(selected); + let name = SharedString::from(self.host_name(&owner)); + (owner, name) + }); } /// Raise a desktop notification when enabled and the window is not @@ -5497,65 +5595,6 @@ impl ChatScreen { } } -/// The directory that holds `HEAD` for a checkout, or `None` when `root` -/// is not one. Supports worktrees, whose `.git` is a file that points at -/// the real git dir. -fn git_dir(root: &std::path::Path) -> Option { - let dot_git = root.join(".git"); - if dot_git.is_dir() { - return Some(dot_git); - } - let pointer = std::fs::read_to_string(&dot_git).ok()?; - let target = pointer.trim().strip_prefix("gitdir:")?.trim(); - let target = std::path::Path::new(target); - Some(if target.is_absolute() { - target.to_path_buf() - } else { - root.join(target) - }) -} - -/// Current git branch from a git dir, or the short commit id when HEAD -/// is detached. `None` when there is no readable `HEAD`. -fn git_branch(git_dir: &std::path::Path) -> Option { - let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; - let head = head.trim(); - match head.strip_prefix("ref: ") { - Some(reference) => Some( - reference - .strip_prefix("refs/heads/") - .unwrap_or(reference) - .to_string(), - ), - // Detached: a hex id. Anything else is a corrupt HEAD. - None => head - .get(..7) - .filter(|id| id.bytes().all(|byte| byte.is_ascii_hexdigit())) - .map(str::to_string), - } -} - -/// True when a watcher event means the branch may have changed: a semantic -/// change to a `HEAD` path (write, create, remove, or the rename pair of an -/// atomic replacement), or a rescan the backend requires. Access-only events -/// (open, read, close) are dropped: the branch read they would trigger emits -/// those same events again under Linux inotify, looping the watcher at full -/// CPU while idle (#945). Real writes still arrive as `Modify` on every -/// backend, so no true change is lost; `Any`/`Other` stay forwarded for -/// backends that cannot classify. -fn head_change_event(event: ¬ify::Event) -> bool { - if event.need_rescan() { - return true; - } - if matches!(event.kind, notify::EventKind::Access(_)) { - return false; - } - event - .paths - .iter() - .any(|path| path.file_name().is_some_and(|name| name == "HEAD")) -} - /// Last path component of a project root, for chips and the sidebar. /// Upper-case section heading in the sidebar. fn section_label(text: &'static str) -> Div { diff --git a/apps/maple-agent/app/src/ui/chat/picker.rs b/apps/maple-agent/app/src/ui/chat/picker.rs new file mode 100644 index 000000000..e009320d2 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/picker.rs @@ -0,0 +1,450 @@ +//! The project picker: one dialog for every host, with a search box over +//! the target host's recent projects and folder suggestions, driven by +//! the keyboard. + +use gpui::{Context, Div, SharedString, div, prelude::*, px}; + +use super::ChatScreen; +use super::sidebar::root_display_name; +use crate::ui::icons::icon; +use crate::ui::text_input::TextInput; +use crate::ui::theme; +use crate::ui::widgets; + +/// What one row of the project picker stands for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PickerRowKind { + /// A project this host used before. + Recent, + /// A folder the host found for the typed text. + Suggestion, + /// The typed text itself, when it looks like a path. + OpenPath, +} + +#[derive(Clone)] +pub(super) struct PickerRow { + pub(super) kind: PickerRowKind, + pub(super) path: String, + pub(super) title: SharedString, + pub(super) subtitle: Option, +} + +/// The project picker while it is open: one search box over the target +/// host's recent projects and folders, keyboard-navigable. +pub(super) struct ProjectPicker { + pub(super) rows: Vec, + pub(super) selected: usize, + /// Bumped per folder request; a late answer is dropped. + generation: u64, + suggestions: Vec, + /// What the rows were built from: the typed text, or the path an + /// arrow filled in. + query: String, + /// The text an arrow just filled in; the box reporting it back is not + /// a new query, so the rows stay put while the highlight moves. + filled: Option, +} + +impl ChatScreen { + /// The project chip or its shortcut: open the picker, or close it when + /// it is open. + pub(super) fn toggle_project_picker(&mut self, cx: &mut Context) { + if self.project_picker.is_some() { + self.close_project_picker(cx); + } else { + self.open_project_picker(cx); + } + } + + /// Open the project picker: a search box over the target host's recent + /// projects and folders, the one way to choose a project on any host. + /// The search box is created once and reused. + pub(super) fn open_project_picker(&mut self, cx: &mut Context) { + self.popup.close(cx); + if self.root_input.is_none() { + let chat = cx.entity().downgrade(); + let key_chat = chat.clone(); + let arrow_chat = chat.clone(); + let application_vim_enabled = self.application_vim_enabled; + let input = cx.new(move |cx| { + TextInput::new("Search folders or enter a path\u{2026}", cx) + .with_tab_index(0) + .application_vim(application_vim_enabled) + .on_key(move |event, _text, _window, cx| { + let Some(chat) = key_chat.upgrade() else { + return false; + }; + match event.keystroke.key.as_str() { + "enter" => chat.update(cx, |chat, cx| chat.project_picker_confirm(cx)), + "escape" => chat.update(cx, |chat, cx| chat.close_project_picker(cx)), + _ => return false, + } + true + }) + .on_vertical(move |delta, _window, cx| { + // The move writes the highlighted path back into + // this input, which is mid-update here: defer it. + let chat = arrow_chat.clone(); + cx.defer(move |cx| { + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| chat.project_picker_move(delta, cx)); + } + }); + true + }) + .on_application_escape(move |_window, cx| { + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| chat.close_project_picker(cx)); + } + }) + }); + cx.observe(&input, |this, input, cx| { + let query = input.read(cx).text(); + // The box reports every change, not only to its text. The + // rows already show this text when it is the query they + // were built from, or the path an arrow filled in. + if this.project_picker.as_ref().is_some_and(|picker| { + picker.query == query || picker.filled.as_deref() == Some(query.as_str()) + }) { + return; + } + this.refresh_project_picker(query, cx); + }) + .detach(); + self.root_input = Some(input); + } else if let Some(input) = self.root_input.clone() { + input.update(cx, |input, cx| input.set_text("", cx)); + } + self.project_picker = Some(ProjectPicker { + rows: Vec::new(), + selected: 0, + generation: 0, + suggestions: Vec::new(), + query: String::new(), + filled: None, + }); + self.root_input_focus_pending = true; + self.refresh_project_picker(String::new(), cx); + cx.notify(); + } + + pub(super) fn close_project_picker(&mut self, cx: &mut Context) { + if self.project_picker.take().is_some() { + // The composer takes the keyboard back. + self.screen_focus_pending = true; + cx.notify(); + } + } + + /// The search text changed: rebuild the rows now from what is known + /// and ask the host for folders that match. + pub(super) fn refresh_project_picker(&mut self, query: String, cx: &mut Context) { + let Some(picker) = self.project_picker.as_mut() else { + return; + }; + picker.query = query.clone(); + picker.filled = None; + picker.selected = 0; + picker.generation += 1; + let generation = picker.generation; + self.rebuild_picker_rows(); + // With nothing typed, the current project's row starts highlighted. + if query.trim().is_empty() + && let Some(current) = self.project_root.as_deref() + && let Some(picker) = self.project_picker.as_mut() + && let Some(index) = picker.rows.iter().position(|row| row.path == current) + { + picker.selected = index; + } + let host = self.host.clone(); + self.call( + async move { host.suggest_directories(query).await }, + cx, + move |this, result, cx| { + let Some(picker) = this.project_picker.as_mut() else { + return; + }; + if picker.generation != generation { + return; + } + if let Ok(suggestions) = result { + picker.suggestions = suggestions; + this.rebuild_picker_rows(); + cx.notify(); + } + }, + ); + } + + /// Rows in display order: the typed path itself when it looks like + /// one, recent projects that match, then the host's folders. + pub(super) fn rebuild_picker_rows(&mut self) { + let Some(mut picker) = self.project_picker.take() else { + return; + }; + let query = picker.query.trim().to_string(); + let needle = query.to_lowercase(); + let mut rows: Vec = Vec::new(); + for root in &self.recent_roots { + if !needle.is_empty() && !root.to_lowercase().contains(&needle) { + continue; + } + rows.push(PickerRow { + kind: PickerRowKind::Recent, + path: root.clone(), + title: SharedString::from(root_display_name(root)), + subtitle: Some(SharedString::from(root.clone())), + }); + } + for suggestion in &picker.suggestions { + if rows.iter().any(|row| row.path == suggestion.path) { + continue; + } + rows.push(PickerRow { + kind: PickerRowKind::Suggestion, + path: suggestion.path.clone(), + title: SharedString::from(suggestion.name.clone()), + subtitle: Some(SharedString::from(suggestion.path.clone())), + }); + } + let looks_like_path = query.starts_with('/') || query.starts_with('~'); + let typed = query.trim_end_matches('/').to_string(); + if looks_like_path && !typed.is_empty() && !rows.iter().any(|row| row.path == typed) { + rows.insert( + 0, + PickerRow { + kind: PickerRowKind::OpenPath, + path: typed.clone(), + title: "Open this path".into(), + subtitle: Some(SharedString::from(typed)), + }, + ); + } + picker.selected = picker.selected.min(rows.len().saturating_sub(1)); + picker.rows = rows; + self.project_picker = Some(picker); + } + + /// Move the highlight and put the highlighted path in the search box, + /// as a shell completes. + pub(super) fn project_picker_move(&mut self, delta: isize, cx: &mut Context) { + let Some(picker) = self.project_picker.as_mut() else { + return; + }; + let len = picker.rows.len(); + if len == 0 { + return; + } + picker.selected = (picker.selected as isize + delta).rem_euclid(len as isize) as usize; + let text = picker.rows[picker.selected].path.clone(); + picker.filled = Some(text.clone()); + if let Some(input) = self.root_input.clone() { + input.update(cx, |input, cx| input.set_text(&text, cx)); + } + cx.notify(); + } + + pub(super) fn project_picker_confirm(&mut self, cx: &mut Context) { + let Some(index) = self.project_picker.as_ref().map(|picker| picker.selected) else { + return; + }; + self.activate_picker_row(index, cx); + } + + /// Open the project a row names. + pub(super) fn activate_picker_row(&mut self, index: usize, cx: &mut Context) { + let Some(path) = self + .project_picker + .as_ref() + .and_then(|picker| picker.rows.get(index)) + .map(|row| row.path.clone()) + else { + return; + }; + self.close_project_picker(cx); + self.select_project_root(path, cx); + } + + /// The project picker: a centered dialog over the pane with a search + /// box, the rows it matched, and the keys that drive it. + pub(super) fn render_project_picker(&self, cx: &mut Context) -> gpui::Stateful
{ + let picker = self.project_picker.as_ref(); + let rows: &[PickerRow] = picker.map(|picker| picker.rows.as_slice()).unwrap_or(&[]); + let selected = picker.map(|picker| picker.selected).unwrap_or(0); + let searching = picker.is_some_and(|picker| !picker.query.trim().is_empty()); + let mut list = div() + .id("project-picker-rows") + .flex() + .flex_col() + .max_h(px(380.)) + .overflow_y_scroll(); + if rows.is_empty() { + list = list.child( + div() + .px_3() + .py_3() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(if searching { + "No folders match" + } else { + "No recent projects on this host yet; type a path" + }), + ); + } + for (index, row) in rows.iter().enumerate() { + let is_selected = index == selected; + let glyph = match row.kind { + PickerRowKind::Recent => "folder-open", + PickerRowKind::Suggestion => "folder", + PickerRowKind::OpenPath => "search", + }; + list = list.child( + div() + .id(SharedString::from(format!("project-picker-row-{index}"))) + .flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .rounded(theme::RADIUS_SM) + .when(is_selected, |row| row.bg(gpui::rgb(theme::bg_input()))) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.activate_picker_row(index, cx); + })) + .child(icon(glyph, px(16.), theme::text_muted())) + .child( + div() + .flex() + .flex_col() + .min_w_0() + .flex_1() + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_primary())) + .line_clamp(1) + .text_ellipsis() + .child(row.title.clone()), + ) + .when_some(row.subtitle.clone(), |col, subtitle| { + col.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .line_clamp(1) + .text_ellipsis() + .child(subtitle), + ) + }), + ) + .when(is_selected, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("Enter"), + ) + }), + ); + } + let hint = |keys: &'static str, label: &'static str| { + div() + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child( + div() + .px_1() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_input())) + .font_family(crate::assets::FONT_MONO) + .child(keys), + ) + .child(label) + }; + div() + .id("project-picker-backdrop") + .absolute() + .size_full() + .top_0() + .left_0() + .occlude() + .bg(theme::scrim()) + .flex() + .items_start() + .justify_center() + .pt(px(96.)) + .on_click(cx.listener(|this, _event, _window, cx| { + this.close_project_picker(cx); + })) + .child( + div() + .id("project-picker") + .role(gpui::Role::Dialog) + .aria_label("Choose a project") + .w(px(640.)) + .max_w_full() + .rounded(theme::RADIUS_XL) + .shadow_lg() + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .flex() + .flex_col() + .on_click(|_event, _window, cx| cx.stop_propagation()) + .child( + div() + .flex() + .flex_col() + .gap_2() + .px_4() + .pt_4() + .pb_2() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_primary())) + .child("Choose a project"), + ) + .child( + div() + .flex() + .gap_1() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("on") + .child(self.target_host_label.clone()), + ), + ) + .when_some(self.root_input.clone(), |col, input| { + col.child(widgets::input_frame().text_sm().child(input)) + }), + ) + .child(div().px_2().pb_2().child(list)) + .child( + div() + .flex() + .items_center() + .gap_4() + .px_4() + .py_2() + .border_t_1() + .border_color(gpui::rgb(theme::border())) + .child(hint("\u{2191}\u{2193}", "Navigate")) + .child(hint("Enter", "Open")) + .child(hint("Esc", "Close")), + ), + ) + } +} diff --git a/apps/maple-agent/app/src/ui/chat/queue.rs b/apps/maple-agent/app/src/ui/chat/queue.rs index c1ff48d25..e6ab8704e 100644 --- a/apps/maple-agent/app/src/ui/chat/queue.rs +++ b/apps/maple-agent/app/src/ui/chat/queue.rs @@ -43,14 +43,12 @@ impl ChatScreen { return; } self.queue_busy = true; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let queue_id = queue_id.to_string(); let target = session_id.clone(); self.call( async move { - backend - .cancel_queued_message(&user_id, &session_id, &queue_id) + host.cancel_queued_message(session_id.clone(), queue_id.clone()) .await }, cx, @@ -83,15 +81,13 @@ impl ChatScreen { }; let text = item.text.clone(); self.queue_busy = true; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let queue_id = queue_id.to_string(); let target = session_id.clone(); let held_id = queue_id.clone(); self.call( async move { - backend - .begin_queued_message_edit(&user_id, &session_id, &queue_id) + host.begin_queued_message_edit(session_id.clone(), queue_id.clone()) .await }, cx, @@ -169,14 +165,12 @@ impl ChatScreen { } fn release_queue_hold(&self, session_id: &str, queue_id: &str, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let queue_id = queue_id.to_string(); self.call( async move { - backend - .end_queued_message_edit(&user_id, &session_id, &queue_id) + host.end_queued_message_edit(session_id.clone(), queue_id.clone()) .await }, cx, diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index d2ee1c19c..e9a38a2ec 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -30,15 +30,16 @@ use crate::ui::text_input::TextInput; use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; +use maple_agent::host::HostId; +use std::collections::BTreeMap; /// What the sidebar asks the screen to do. #[derive(Clone, Debug)] pub(super) enum SidebarEvent { /// Show a task. Select(String), - /// A session changed on the backend (a rename); the screen owns the - /// canonical list and pushes it back. - SessionChanged(AgentSessionSummary), + /// The user renamed a task; the screen sends it to the task's host. + RenameTask { session_id: String, name: String }, /// Move a task between active, settled, and archived. The runtime /// owns the state; its updated record moves the row. SetState { @@ -51,10 +52,24 @@ pub(super) enum SidebarEvent { RemoveRoot(String), /// Trust or untrust a project. SetTrust { path: String, trusted: bool }, + /// A project's menu opened: is the project trusted on the target + /// host? The screen answers through [`Sidebar::set_menu_trust`]. + ProjectTrust(String), /// Open the folder picker for a new project. ChooseProject, /// Something to tell the user. Notice(SharedString), + /// Show only one host's tasks (`None` for every host); new tasks go to + /// that host. + HostFilter(Option), +} + +/// One host the sidebar can filter by and badge rows with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct SidebarHost { + pub(super) id: HostId, + pub(super) name: SharedString, + pub(super) online: bool, } /// Activity a task row indicates beside its title. @@ -95,14 +110,15 @@ pub(super) struct SidebarRow { pub(super) menu_delete_id: SharedString, pub(super) menu_reopen_id: SharedString, pub(super) title: SharedString, - /// Display name of the task's project, shown on every row. + /// Display name of the task's project, shown on every row, with the + /// host's name after it when more than one host is known. pub(super) project_name: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, } impl SidebarRow { - fn build(session: &AgentSessionSummary, project_name: &str) -> Self { + fn build(session: &AgentSessionSummary, project_label: &str) -> Self { let id = &session.id; Self { id: Arc::from(id.as_str()), @@ -121,7 +137,7 @@ impl SidebarRow { menu_delete_id: SharedString::from(format!("delete-task-{id}")), menu_reopen_id: SharedString::from(format!("reopen-task-{id}")), title: SharedString::from(session.title.clone()), - project_name: SharedString::from(project_name.to_string()), + project_name: SharedString::from(project_label.to_string()), search: session.title.to_lowercase(), } } @@ -289,8 +305,8 @@ pub(super) struct SwitcherRoot { } pub(super) struct Sidebar { + /// Runs backend futures; see [`Self::call`]. backend: Arc, - user_id: String, chat: WeakEntity, // What the screen pushes in. sessions: Vec, @@ -331,7 +347,15 @@ pub(super) struct Sidebar { rename: Option, rename_input: Option>, rename_focus_pending: bool, - // Persisted in the app settings. + /// Every known host, local first. More than one turns on the row badge + /// and the host block of the switcher menu. + hosts: Vec, + /// Which host owns each task; unknown means the local host. + session_hosts: HashMap, + /// Show only this host's tasks. + host_filter: Option, + // Persisted in the app settings, per host; merged here because task ids + // are unique across hosts. pinned_tasks: Vec, project_names: HashMap, // Application Vim's view of the rows. @@ -347,11 +371,21 @@ impl EventEmitter for Sidebar {} impl Sidebar { pub(super) fn new( backend: Arc, - user_id: String, chat: WeakEntity, settings: &crate::settings::AppSettings, cx: &mut Context, ) -> Self { + // Every host's persisted task state, merged: ids are unique. + let mut pinned_tasks = Vec::new(); + let mut project_names: HashMap = HashMap::new(); + for state in settings.hosts.values() { + pinned_tasks.extend(state.pinned_tasks.iter().cloned()); + for (root, name) in &state.project_names { + project_names + .entry(root.clone()) + .or_insert_with(|| name.clone()); + } + } let application_vim_enabled = settings.application_vim_enabled; let search_chat = chat.clone(); let search_input = cx.new(move |cx| { @@ -370,7 +404,6 @@ impl Sidebar { .detach(); Self { backend, - user_id, chat, sessions: Vec::new(), selected: None, @@ -401,8 +434,15 @@ impl Sidebar { rename: None, rename_input: None, rename_focus_pending: false, - pinned_tasks: settings.pinned_tasks.clone(), - project_names: settings.project_names.clone(), + hosts: vec![SidebarHost { + id: HostId::local(), + name: super::LOCAL_HOST_NAME.into(), + online: true, + }], + session_hosts: HashMap::new(), + host_filter: None, + pinned_tasks, + project_names, vim_selected: None, vim_by_row: Vec::new(), vim_order: Vec::new(), @@ -465,6 +505,77 @@ impl Sidebar { cx.notify(); } + /// Replace the host list and the task-to-host map. Rows re-label when + /// the host count crosses one. + pub(super) fn set_hosts( + &mut self, + hosts: Vec, + session_hosts: HashMap, + cx: &mut Context, + ) { + if self.hosts == hosts && self.session_hosts == session_hosts { + return; + } + self.hosts = hosts; + self.session_hosts = session_hosts; + self.rebuild_sections(); + cx.notify(); + } + + /// The host a task belongs to; the local host when unknown. + fn host_of(&self, session_id: &str) -> HostId { + self.session_hosts + .get(session_id) + .cloned() + .unwrap_or_else(HostId::local) + } + + fn host_name(&self, id: &HostId) -> Option<&SharedString> { + self.hosts + .iter() + .find(|host| &host.id == id) + .map(|host| &host.name) + } + + /// The user chose a host to show tasks from, or every host. The screen + /// learns of it so new tasks target that host. An offline host cannot + /// take new tasks, so it cannot be the filter either. + pub(super) fn set_host_filter(&mut self, host: Option, cx: &mut Context) { + if self.switcher_menu_open() { + self.popup.close(cx); + } + if let Some(offline) = host + .as_ref() + .and_then(|host| self.hosts.iter().find(|entry| &entry.id == host)) + .filter(|entry| !entry.online) + { + cx.emit(SidebarEvent::Notice( + format!("{} is offline", offline.name).into(), + )); + cx.notify(); + return; + } + if self.host_filter == host { + cx.notify(); + return; + } + self.host_filter = host.clone(); + self.rebuild_sections(); + cx.emit(SidebarEvent::HostFilter(host)); + cx.notify(); + } + + /// The screen's host filter, pushed here when it reset it (the host + /// dropped or was removed); the screen already knows. + pub(super) fn show_host_filter(&mut self, host: Option, cx: &mut Context) { + if self.host_filter == host { + return; + } + self.host_filter = host; + self.rebuild_sections(); + cx.notify(); + } + pub(super) fn set_recent_roots(&mut self, roots: Vec, cx: &mut Context) { if self.recent_roots == roots { return; @@ -510,12 +621,25 @@ impl Sidebar { /// Forget tasks that left the app (their project was removed). pub(super) fn forget_tasks(&mut self, ids: &[String], cx: &mut Context) { - let before = self.pinned_tasks.len(); + // Pins persist per host; rewrite the pins of every host that lost + // a pinned task. + let hosts: HashSet = ids + .iter() + .filter(|id| self.pinned_tasks.contains(*id)) + .map(|id| self.host_of(id)) + .collect(); self.pinned_tasks .retain(|candidate| !ids.contains(candidate)); - if self.pinned_tasks.len() != before { - let pinned = self.pinned_tasks.clone(); - persist_settings(move |settings| settings.pinned_tasks = pinned); + for host_id in hosts { + let pinned: Vec = self + .pinned_tasks + .iter() + .filter(|id| self.host_of(id) == host_id) + .cloned() + .collect(); + persist_settings(move |settings| { + settings.host_state_mut(&host_id).pinned_tasks = pinned; + }); } self.rebuild_sections(); cx.notify(); @@ -624,6 +748,11 @@ impl Sidebar { self.popup.is_open(&SidebarPopup::Switcher) } + #[cfg(test)] + pub(super) fn host_filter(&self) -> Option<&HostId> { + self.host_filter.as_ref() + } + #[cfg(test)] pub(super) fn task_menu(&self) -> Option<&str> { match self.popup.open_key() { @@ -753,6 +882,7 @@ impl Sidebar { let filter = self.filter.as_str(); let filtering = !filter.is_empty(); let scoped = self.project_filter.as_deref(); + let host_scoped = self.host_filter.clone(); let pinned_ids: HashSet<&str> = self.pinned_tasks.iter().map(String::as_str).collect(); let mut pinned = Vec::new(); let mut active = Vec::new(); @@ -772,6 +902,12 @@ impl Sidebar { if scoped.is_some_and(|scoped| scoped != root) { continue; } + if host_scoped + .as_ref() + .is_some_and(|host| self.host_of(&session.id) != *host) + { + continue; + } let matches = !filtering || self.rows[index].search.contains(filter) || root_search @@ -806,12 +942,17 @@ impl Sidebar { self.active_rows = active; self.settled_rows = settled; self.archived_rows = archived; - self.scope_label = self - .project_filter - .as_deref() - .map(|root| self.root_name(root)) - .map(SharedString::from) - .unwrap_or_else(|| "All projects".into()); + self.scope_label = match ( + self.project_filter.as_deref(), + self.host_filter + .as_ref() + .and_then(|host| self.host_name(host).cloned()), + ) { + (Some(root), _) => SharedString::from(self.root_name(root)), + (None, Some(host)) => host, + // Every host's projects: the scope is still projects. + (None, None) => "All projects".into(), + }; let recent_roots: HashSet<&str> = self.recent_roots.iter().map(String::as_str).collect(); let mut fresh_roots: Vec<&str> = session_roots .iter() @@ -968,7 +1109,7 @@ impl Sidebar { && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id && row.title.as_ref() == session.title - && self.root_name_matches(&session.project_root, &row.project_name) + && row.project_name.as_ref() == self.row_project_label(session) }); if fresh { return; @@ -976,10 +1117,23 @@ impl Sidebar { self.rows = self .sessions .iter() - .map(|session| SidebarRow::build(session, &self.root_name(&session.project_root))) + .map(|session| SidebarRow::build(session, &self.row_project_label(session))) .collect(); } + /// The project name a row shows, with the host after it once more than + /// one host is known: the badge that tells two hosts' tasks apart. + fn row_project_label(&self, session: &AgentSessionSummary) -> String { + let root = self.root_name(&session.project_root); + if self.hosts.len() <= 1 { + return root; + } + match self.host_name(&self.host_of(&session.id)) { + Some(host) => format!("{root} \u{b7} {host}"), + None => root, + } + } + fn search_changed(&mut self, input: &Entity, cx: &mut Context) { let filter = input.read(cx).text_ref().trim().to_lowercase(); if filter != self.filter { @@ -1047,8 +1201,16 @@ impl Sidebar { } self.rebuild_sections(); cx.notify(); - let pinned = self.pinned_tasks.clone(); - persist_settings(move |settings| settings.pinned_tasks = pinned); + let host_id = self.host_of(session_id); + let pinned: Vec = self + .pinned_tasks + .iter() + .filter(|id| self.host_of(id) == host_id) + .cloned() + .collect(); + persist_settings(move |settings| { + settings.host_state_mut(&host_id).pinned_tasks = pinned; + }); } /// Whether a collapsible section shows its rows. @@ -1110,22 +1272,6 @@ impl Sidebar { cx.notify(); } - /// Whether `name` is the display name of `root`, without building the - /// name the way `root_name` does. - fn root_name_matches(&self, root: &str, name: &str) -> bool { - match self - .project_names - .get(root) - .filter(|name| !name.trim().is_empty()) - { - Some(stored) => stored == name, - None => match std::path::Path::new(root).file_name() { - Some(file) => file.to_string_lossy().as_ref() == name, - None => root == name, - }, - } - } - // ---- Rename ------------------------------------------------------------ /// Start an inline rename of a task or project in the sidebar. @@ -1219,16 +1365,8 @@ impl Sidebar { } match target { RenameTarget::Task(session_id) => { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - self.call( - async move { backend.rename_session(&user_id, &session_id, name).await }, - cx, - |_this, result, cx| match result { - Ok(session) => cx.emit(SidebarEvent::SessionChanged(session)), - Err(message) => cx.emit(SidebarEvent::Notice(message.into())), - }, - ); + // The screen knows which host owns the task. + cx.emit(SidebarEvent::RenameTask { session_id, name }); } RenameTarget::Project(root) => { if name == root_display_name(&root) { @@ -1237,8 +1375,14 @@ impl Sidebar { self.project_names.insert(root.clone(), name); } self.rebuild_sections(); - let names = self.project_names.clone(); - persist_settings(move |settings| settings.project_names = names); + // Project names are keyed by path; the filtered host owns + // the rename, else the local host. + let names: BTreeMap = + self.project_names.clone().into_iter().collect(); + let host_id = self.host_filter.clone().unwrap_or_else(HostId::local); + persist_settings(move |settings| { + settings.host_state_mut(&host_id).project_names = names; + }); } } } @@ -1270,21 +1414,22 @@ impl Sidebar { return; } self.menu_trust = None; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let path = root.to_string(); - self.call( - async move { backend.project_trust(&user_id, path).await }, - cx, - |this, result, cx| { - if let Ok(status) = result - && this.project_popup.is_open(&status.path) - { - this.menu_trust = Some(status); - cx.notify(); - } - }, - ); + // The screen knows the target host and asks it. + cx.emit(SidebarEvent::ProjectTrust(root.to_string())); + } + + /// The screen's answer to [`SidebarEvent::ProjectTrust`]; shown while + /// that project's menu is still the open one. + pub(super) fn set_menu_trust( + &mut self, + status: AgentProjectTrustStatus, + cx: &mut Context, + ) { + if !self.project_popup.is_open(&status.path) { + return; + } + self.menu_trust = Some(status); + cx.notify(); } /// Open or close the overflow menu of one task row. @@ -1959,21 +2104,64 @@ impl Sidebar { /// The menu the project switcher opens: every project, each with an /// overflow menu of its own, plus a way back to all projects. + /// With more than one host, a host block comes first: every host, + /// then all hosts. Offline hosts stay listed, dimmed, so a host that + /// dropped is still visible; its tasks return with it. fn switcher_menu(&self, window: &mut Window, cx: &mut Context) -> Menu { let mut menu = Menu::new("switcher-menu", px(260.)) .label("Projects") .max_height(px(360.)) - .application_vim(self.application_vim_enabled) - .item( + .application_vim(self.application_vim_enabled); + if self.hosts.len() > 1 { + menu = menu.header("HOSTS").item( MenuItem::new( - ALL_PROJECTS_ID, - "All projects", + "switcher-all-hosts", + "All hosts", |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.set_project_filter(None, cx); + this.set_host_filter(None, cx); }, ) - .current(self.project_filter.is_none()), + .current(self.host_filter.is_none()), ); + for host in &self.hosts { + let filter = host.id.clone(); + let online = host.online; + let item = MenuItem::new( + SharedString::from(format!("switcher-host-{}", host.id)), + host.name.clone(), + move |this: &mut Self, _: &mut Window, cx: &mut Context| { + this.set_host_filter(Some(filter.clone()), cx); + }, + ) + .current(self.host_filter.as_ref() == Some(&host.id)) + .style(move |row| { + row.when(!online, |row| { + row.text_color(gpui::rgb(theme::text_muted())) + }) + }); + menu = menu.item(if online { + item + } else { + item.trailing( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("offline"), + ) + }); + } + menu = menu.separator().header("PROJECTS"); + } + menu = menu.item( + MenuItem::new( + ALL_PROJECTS_ID, + "All projects", + |this: &mut Self, _: &mut Window, cx: &mut Context| { + this.set_project_filter(None, cx); + }, + ) + .current(self.project_filter.is_none()), + ); for root in &self.switcher_roots { let pick = root.root.clone(); let group = root.row_group.clone(); diff --git a/apps/maple-agent/app/src/ui/chat/summaries.rs b/apps/maple-agent/app/src/ui/chat/summaries.rs index 343e34c3a..3c7d8dcc9 100644 --- a/apps/maple-agent/app/src/ui/chat/summaries.rs +++ b/apps/maple-agent/app/src/ui/chat/summaries.rs @@ -138,35 +138,24 @@ impl ChatScreen { } let tool_name = item.title.clone().unwrap_or_else(|| item.item_type.clone()); log::debug!("Requesting summary for {item_id} ({tool_name})"); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.backend_for(&session_id); let generation = self.summary_generation; self.pending_summaries += 1; let store_id = item_id.clone(); self.call( async move { let summary = if thinking { - backend - .summarize_thinking(&user_id, &session_id, output) - .await? + host.summarize_thinking(session_id.clone(), output).await? } else { - backend - .summarize_tool_call(&user_id, &session_id, tool_name, input, output) + host.summarize_tool_call(session_id.clone(), tool_name, input, output) .await? }; - if let Some(summary) = &summary { - let summary = summary.clone(); - let store = backend.clone(); - tokio::task::spawn_blocking(move || { - if let Err(error) = store.store_tool_summary_blocking( - &user_id, - &session_id, - &store_id, - &summary, - ) { - log::warn!("Cannot store tool summary: {error}"); - } - }); + if let Some(summary) = &summary + && let Err(error) = host + .store_tool_summary(session_id.clone(), store_id, summary.clone()) + .await + { + log::warn!("Cannot store tool summary: {error}"); } Ok::<_, String>(summary) }, @@ -250,14 +239,12 @@ impl ChatScreen { }; for id in wanted { self.attachment_requests.insert(id.clone()); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.session_backend(); let session = session_id.clone(); let attachment_id = id.clone(); self.call( async move { - backend - .read_image_attachment(&user_id, &session, &attachment_id) + host.read_image_attachment(session.clone(), attachment_id.clone()) .await }, cx, diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 4e8289b35..cc26d2469 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5,14 +5,15 @@ mod state_tests { use crate::ui::chat::cache::{INLINE_PARSE_LIMIT, MAX_DIFF_LINES, ORDINAL_SPACING}; use crate::ui::chat::composer::SideThreadTurn; + use crate::ui::chat::hosts::RemoteBootstrap; use crate::ui::chat::images::{MAX_DRAFT_IMAGES, encode_data_url}; use crate::ui::chat::sidebar::{RenameTarget, SidebarEvent, TaskMove}; use crate::ui::chat::transcript::{diff_lines_for, maple_display_text, tool_label_title}; use crate::ui::chat::*; use gpui::TestAppContext; - use maple_agent::agent::{ - AgentMcpServer, AgentMcpTransport, AgentSessionIntegrationKind, AgentTaskState, - }; + use maple_agent::agent::AgentTaskState; + use maple_agent::host::HostBootstrap; + use maple_remote::manager::HostStatus; fn summary(id: &str, title: &str) -> AgentSessionSummary { summary_at(id, title, "/tmp/proj") @@ -76,14 +77,18 @@ mod state_tests { fn screen(cx: &mut TestAppContext) -> Entity { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); // This gpui's test scheduler flags activity on other threads unless // parking is allowed; the backend runtime and image encoder run on tokio. cx.executor().allow_parking(); let screen = cx.new(|cx| { - let mut screen = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut screen = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); screen.selected_session = Some("s1".to_string()); screen }); @@ -106,7 +111,7 @@ mod state_tests { let backend = { let _guard = SETTINGS_LOCK.lock(); std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ) }; @@ -143,7 +148,8 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let host = backend.local_host("user"); + let mut chat = ChatScreen::new_without_start(backend, host, "user".to_string(), cx); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.trust_prompts = false; @@ -424,28 +430,19 @@ mod state_tests { let _ = std::fs::remove_dir_all(&dir); let config = dir.join("maple-agent"); std::fs::create_dir_all(&config).unwrap(); - std::fs::write( - config.join("settings.json"), - r#"{"tool_details":false,"default_web_enabled":false}"#, - ) - .unwrap(); + std::fs::write(config.join("settings.json"), r#"{"tool_details":false}"#).unwrap(); let previous = std::env::var_os("XDG_CONFIG_HOME"); unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) }; // This gpui's test scheduler flags activity on other threads unless // parking is allowed; the backend runtime and image encoder run on tokio. cx.executor().allow_parking(); let screen = cx.new(|cx| { - ChatScreen::new_inner( - std::sync::Arc::new( - crate::backend::AgentBackend::new( - "http://127.0.0.1:9".to_string(), - String::new(), - ) + let backend = std::sync::Arc::new( + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), - ), - "user".to_string(), - cx, - ) + ); + let host = backend.local_host("user"); + ChatScreen::new_inner(backend, host, "user".to_string(), cx) }); match previous { Some(value) => unsafe { std::env::set_var("XDG_CONFIG_HOME", value) }, @@ -454,7 +451,6 @@ mod state_tests { let _ = std::fs::remove_dir_all(&dir); screen.update(cx, |this, _cx| { assert!(!this.tool_details); - assert!(!this.default_web_enabled); }); } @@ -804,9 +800,7 @@ mod state_tests { theme::Preference::parse(&crate::settings::load_settings().theme), cx, ); - let backend = Arc::new( - AgentBackend::new("http://127.0.0.1:9".into(), String::new()).unwrap(), - ); + let backend = Arc::new(AgentBackend::new("http://127.0.0.1:9".into()).unwrap()); cx.open_window( gpui::WindowOptions { window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds::centered( @@ -823,8 +817,12 @@ mod state_tests { |window, cx| { theme::resolve(window.appearance()); cx.new(|cx| { - let mut chat = - ChatScreen::new_mounted(backend, "fixture-user".into(), cx); + let mut chat = ChatScreen::new_mounted( + backend.clone(), + backend.local_host("fixture-user"), + "fixture-user".to_string(), + cx, + ); chat.booting = false; chat.selected_session = Some("s1".into()); chat.replace_timeline(vec![user_item( @@ -1179,11 +1177,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.application_vim_enabled = false; @@ -1299,23 +1302,26 @@ mod state_tests { event, }; // A stream for a closed question changes nothing. - assert!( - !this.apply_service_event( - event("btw-1", SideQuestionEvent::Chunk("old".into())), - cx - ) - ); + assert!(!this.apply_service_event( + &HostId::local(), + event("btw-1", SideQuestionEvent::Chunk("old".into())), + cx + )); assert!(this.apply_service_event( + &HostId::local(), event("btw-2", SideQuestionEvent::Chunk("Because ".into())), cx )); - assert!( - this.apply_service_event( - event("btw-2", SideQuestionEvent::Chunk("so.".into())), - cx - ) - ); - assert!(this.apply_service_event(event("btw-2", SideQuestionEvent::Finished), cx)); + assert!(this.apply_service_event( + &HostId::local(), + event("btw-2", SideQuestionEvent::Chunk("so.".into())), + cx + )); + assert!(this.apply_service_event( + &HostId::local(), + event("btw-2", SideQuestionEvent::Finished), + cx + )); let btw = this.btw.as_ref().expect("panel stays open"); assert_eq!(btw.turns[0].answer, "Because so."); assert_eq!(btw.revision, 2); @@ -1333,7 +1339,11 @@ mod state_tests { // While the thread is open, a plain message joins it instead of // going to the task; a command still runs as a command. let live_id = this.btw.as_ref().unwrap().request_id.clone(); - assert!(this.apply_service_event(event(&live_id, SideQuestionEvent::Finished), cx)); + assert!(this.apply_service_event( + &HostId::local(), + event(&live_id, SideQuestionEvent::Finished), + cx + )); this.btw.as_mut().unwrap().turns[1].answer = "Then that.".into(); this.send_text("plain follow-up".to_string(), cx); let btw = this.btw.as_ref().expect("thread continues"); @@ -1581,8 +1591,11 @@ mod state_tests { }); } + /// The host owns the filesystem, so it validates the path; the screen + /// only refuses an empty entry and a second selection while one is in + /// flight. #[gpui::test] - fn test_project_selection_rejects_reentry_and_relative_paths(cx: &mut TestAppContext) { + fn test_project_selection_rejects_reentry_and_blank_paths(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -1590,11 +1603,11 @@ mod state_tests { this.select_project_root(absolute_fixture_root("other"), cx); assert!(this.root_selecting); this.root_selecting = false; - this.select_project_root("relative".to_string(), cx); + this.select_project_root(" ".to_string(), cx); assert!(!this.root_selecting); assert_eq!( this.notice.as_ref().map(SharedString::as_ref), - Some("Enter an absolute directory path") + Some("Enter a directory path") ); }); } @@ -1640,52 +1653,366 @@ mod state_tests { }); } - /// The header names what the pane shows: an empty pane is a new task - /// even while the list still marks a row. + /// The boot auto-select opens the visible project's latest task on + /// the target host; a remote task under the same path is not it. #[gpui::test] - fn test_header_says_new_task_while_the_pane_is_empty(cx: &mut TestAppContext) { + fn test_session_list_auto_select_stays_on_the_target_host(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); - screen.update(cx, |this, _cx| { - this.sessions = vec![summary_at("s1", "Hello", "/work/alpha")]; - this.selected_session = Some("s1".to_string()); - this.replace_timeline(Vec::new()); + screen.update(cx, |this, cx| { + this.project_root = Some("/work/alpha".to_string()); + this.selected_session = None; + let remote = HostId::new("remote-key".to_string()); + this.hosts + .insert(remote.clone(), ChatHost::saved("Box".to_string())); + this.apply_host_session_list( + &remote, + vec![summary_at("r1", "Remote", "/work/alpha")], + cx, + ); + let requested = this.selection_generation; + + this.apply_session_list(vec![summary_at("s1", "Local", "/work/beta")], requested, cx); + + // Nothing local under /work/alpha: the empty screen shows + // rather than the remote task opening and dragging the target + // along, and no task is created for it. + assert!(!this.session_setup_pending); + assert_eq!(this.selected_session, None); + assert!(this.target_host.is_local()); assert_eq!(this.selected_title.as_ref(), "New Task"); + }); + } + + /// The host the last new task ran on is the target again at launch: + /// startup holds its auto-select until that host connects, then opens + /// the host's latest task under its saved project. + #[gpui::test] + fn test_launch_restores_the_last_task_host(cx: &mut TestAppContext) { + use maple_agent::agent::AgentDesktopQueueSnapshot; + use maple_agent::host::HostSessionDefaults; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("user") as Arc; + let mut entry = ChatHost::local(backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.restore_host = Some(remote.clone()); + this.selected_session = None; + this.project_root = Some("/work/local".to_string()); + + // The local list arrives first: no auto-select, no new task. + let requested = this.selection_generation; + this.apply_session_list( + vec![summary_at("s1", "Local", "/work/local")], + requested, + cx, + ); + assert_eq!(this.selected_session, None); + assert!(!this.session_setup_pending); + assert!(this.target_host.is_local()); + + // The remembered host connects: it is the target, its project + // shows, and its latest task opens. + let latest = maple_agent::agent::AgentSessionDetail { + session: summary_at("r1", "Remote", "/work/remote"), + timeline: vec![user_item("u1", "hi")], + mcp_errors: Vec::new(), + queue: AgentDesktopQueueSnapshot { + revision: 0, + items: Vec::new(), + }, + }; + let boot = HostBootstrap { + project_root: Some("/work/remote".to_string()), + sessions: vec![summary_at("r1", "Remote", "/work/remote")], + recent_roots: vec!["/work/remote".to_string()], + latest: Some(latest), + session_defaults: HostSessionDefaults::default(), + }; + this.apply_remote_bootstrap(remote.clone(), boot, None, HashMap::new(), cx); + assert_eq!(this.restore_host, None); + assert_eq!(this.target_host, remote); + assert_eq!(this.project_root.as_deref(), Some("/work/remote")); + assert_eq!(this.selected_session.as_deref(), Some("r1")); + assert_eq!(this.selected_title.as_ref(), "Remote"); + }); + } + + /// A host's list replaces every row it names, even one filed under + /// another host by an earlier event, so a task never shows twice. + #[gpui::test] + fn test_host_session_list_never_duplicates_a_task(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + this.hosts + .insert(remote.clone(), ChatHost::saved("Box".to_string())); + // The task arrived once with no host mapping (filed local), + // once under the remote host: both stale by the time its + // host lists it. + this.sessions = vec![ + summary_at("r1", "Remote", "/work/remote"), + summary_at("s1", "Local", "/work/local"), + ]; + this.session_hosts.remove("r1"); + this.apply_host_session_list( + &remote, + vec![ + summary_at("r1", "Remote", "/work/remote"), + summary_at("r2", "Other", "/work/remote"), + ], + cx, + ); + let mut ids: Vec<&str> = this.sessions.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["r1", "r2", "s1"]); + assert_eq!(this.host_of("r1"), remote); + assert!(this.host_of("s1").is_local()); + + // Listing again, and listing the local host, keeps one row each. + this.apply_host_session_list(&remote, vec![summary_at("r1", "Remote", "/w")], cx); + this.apply_host_session_list(&HostId::local(), vec![summary_at("s1", "L", "/w")], cx); + let mut ids: Vec<&str> = this.sessions.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["r1", "s1"]); + }); + } + + /// Calls about a task go to the host that owns it, whatever host new + /// tasks target: enabling an integration on a remote task must not + /// ask the local runtime, which does not know the task. + #[gpui::test] + fn test_task_calls_go_to_the_owning_host(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let thin = |backend: &Arc| Arc::as_ptr(backend) as *const (); + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let local_backend = this.host.clone(); + assert_ne!(thin(&remote_backend), thin(&local_backend)); + this.hosts + .insert(remote.clone(), ChatHost::local(remote_backend.clone())); + this.session_hosts.insert("r1".to_string(), remote.clone()); + this.selected_session = Some("r1".to_string()); + + // New tasks still target the local host. + assert!(this.target_host.is_local()); + assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); + assert_eq!(thin(&this.backend_for("r1")), thin(&remote_backend)); + assert_eq!(thin(&this.backend_for("s1")), thin(&local_backend)); + + // With the sidebar filtered on the local host, opening the + // remote task leaves the target where it is: `host` is the + // target's backend and the task's calls go to its own host. + this.host_filter = Some(HostId::local()); + this.set_active_session( + summary_at("r1", "Remote", "/work/remote"), + vec![user_item("u1", "hi")], + HashMap::new(), + cx, + ); + assert!(this.target_host.is_local()); + assert_eq!(thin(&this.host), thin(&local_backend)); + assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); + + // Without the filter the selection moves the target along, and + // `host` follows the target. + this.host_filter = None; + this.set_active_session( + summary_at("r1", "Remote", "/work/remote"), + vec![user_item("u1", "hi")], + HashMap::new(), + cx, + ); + assert_eq!(this.target_host, remote); + assert_eq!(thin(&this.host), thin(&remote_backend)); + assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); + }); + } + /// The header names the host of the task on screen, not the target: + /// a local task stays labelled local after the target moves to a + /// remote host, and the new-task screen shows no task host at all. + #[gpui::test] + fn test_header_names_the_open_tasks_host(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.sessions = vec![summary_at("s1", "Local", "/work/local")]; + this.selected_session = Some("s1".to_string()); this.replace_timeline(vec![user_item("u1", "hi")]); - assert_eq!(this.selected_title.as_ref(), "Hello"); + assert_eq!( + this.selected_host.as_ref().map(|(_, name)| name.as_ref()), + Some(LOCAL_HOST_NAME) + ); + + // Retargeting new tasks does not relabel the open task. + this.host_filter = Some(remote.clone()); + assert!(this.set_target_host(remote.clone(), cx)); + assert_eq!(this.target_host_label.as_ref(), "Box"); + assert_eq!( + this.selected_host.as_ref().map(|(_, name)| name.as_ref()), + Some(LOCAL_HOST_NAME) + ); + + // The new-task screen has no task host; the target shows. + this.clear_selected_session_presentation(cx); + assert_eq!(this.selected_host, None); + }); + } + /// No task exists until the first message: picking another host on + /// the empty screen moves the target and its project context, and + /// creates nothing anywhere. + #[gpui::test] + fn test_switching_host_on_the_empty_screen_creates_nothing(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + entry.project_root = Some("/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.project_root = Some("/work/local".to_string()); + this.selected_session = None; this.replace_timeline(Vec::new()); - assert_eq!(this.selected_title.as_ref(), "New Task"); + assert_eq!( + this.selected_host, None, + "the empty screen shows the target chip" + ); + + this.pick_host(remote.clone(), cx); + assert_eq!(this.target_host, remote); + assert_eq!(this.selected_session, None); + assert!( + !this.session_setup_pending, + "nothing is created on the new host" + ); + assert_eq!(this.project_root.as_deref(), Some("/work/remote")); + assert_eq!(this.selected_host, None); + // The first message now goes to the new target. + this.booting = false; + this.send_text("hello".to_string(), cx); + assert!(this.session_setup_pending); + assert_eq!(this.target_host, remote); }); } - /// While a project selection is landing, neither "New Task" nor a - /// first send may run ahead of it. + /// A host switched while a draft shows moves the draft: it stays a + /// draft under the new host's project, and neither host's list refresh + /// opens a task over the message being typed. #[gpui::test] - fn test_new_task_waits_for_a_project_selection(cx: &mut TestAppContext) { + fn test_switching_host_keeps_the_draft(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { - this.project_root = Some("/work/alpha".to_string()); - this.root_selecting = true; - let generation = this.selection_generation; + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + entry.project_root = Some("/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.project_root = Some("/work/local".to_string()); + this.sessions = vec![summary_at("s1", "Local", "/work/local")]; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(vec![user_item("u1", "hi")]); + this.new_session(cx); - assert!(!this.session_setup_pending); - assert_eq!(this.selection_generation, generation); - assert_eq!(this.selected_session.as_deref(), Some("s1")); - assert!(this.notice.is_some()); + assert!(this.draft); - this.booting = false; + this.pick_host(remote.clone(), cx); + assert_eq!(this.target_host, remote); + assert!(this.draft, "the draft follows the target"); + assert_eq!(this.selected_session, None); + assert_eq!(this.project_root.as_deref(), Some("/work/remote")); + + // The remote host's tasks land, and the local list refreshes: + // neither opens a task under the draft. + this.apply_host_session_list( + &remote, + vec![summary_at("r1", "Remote", "/work/remote")], + cx, + ); + let generation = this.selection_generation; + this.apply_session_list( + vec![summary_at("s1", "Local", "/work/local")], + generation, + cx, + ); + assert!(this.draft); + assert_eq!(this.selected_session, None); + assert_eq!(this.loading_session, None); + }); + } + + /// The remembered host connecting at launch becomes the target, but a + /// draft the user started meanwhile is not replaced by that host's + /// latest task: its text belongs to the task it creates there. + #[gpui::test] + fn test_restored_host_does_not_replace_a_draft(cx: &mut TestAppContext) { + use maple_agent::agent::AgentDesktopQueueSnapshot; + use maple_agent::host::HostSessionDefaults; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("user") as Arc; + let mut entry = ChatHost::local(backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.restore_host = Some(remote.clone()); this.selected_session = None; - this.send_text("hello".to_string(), cx); - assert!(!this.session_setup_pending); - assert!(this.pending_first_send.is_none()); + this.project_root = Some("/work/local".to_string()); + this.booting = true; + + this.new_session(cx); + assert!(this.draft); + + let latest = maple_agent::agent::AgentSessionDetail { + session: summary_at("r1", "Remote", "/work/remote"), + timeline: vec![user_item("u1", "hi")], + mcp_errors: Vec::new(), + queue: AgentDesktopQueueSnapshot { + revision: 0, + items: Vec::new(), + }, + }; + let boot = HostBootstrap { + project_root: Some("/work/remote".to_string()), + sessions: vec![summary_at("r1", "Remote", "/work/remote")], + recent_roots: vec!["/work/remote".to_string()], + latest: Some(latest), + session_defaults: HostSessionDefaults::default(), + }; + this.apply_remote_bootstrap(remote.clone(), boot, None, HashMap::new(), cx); + + assert_eq!(this.restore_host, None); + assert_eq!(this.target_host, remote, "the host is the target again"); + assert_eq!(this.project_root.as_deref(), Some("/work/remote")); + assert!(this.draft, "the draft stands"); + assert_eq!(this.selected_session, None); + assert!(this.timeline.is_empty()); + assert_eq!(this.selected_title.as_ref(), "New Task"); }); } - /// "New Task" clears the selection and shows the empty screen for the - /// visible project; no task is created until something is sent. + /// "New Task" clears the selection and shows the empty screen with the + /// target's project; no task is created until something is sent. #[gpui::test] fn test_new_task_clears_the_selection_and_creates_nothing(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -1713,15 +2040,16 @@ mod state_tests { this.selection_generation > generation, "a draft is navigation" ); - assert!(!this.web_enabled, "the draft takes the web default"); - assert_eq!(this.sessions.len(), 1, "no row was added"); + assert!(!this.web_enabled, "the draft takes the host's web default"); + assert_eq!(this.sessions.len(), 1, "no row was added anywhere"); }); } - /// The first send on the empty screen creates the task, carrying the - /// draft's mode and model, and sends once the task lands. + /// The first send on the empty screen creates the task on the target + /// host, carrying the draft's mode and model, and sends once the task + /// lands. #[gpui::test] - fn test_first_send_creates_the_task_then_sends(cx: &mut TestAppContext) { + fn test_first_send_creates_the_task_on_the_target_then_sends(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -1749,8 +2077,8 @@ mod state_tests { "nothing is selected until the task lands" ); - // The create lands: the task is selected and the message goes - // out to it. + // The create lands: the task is selected, filed under the + // target, and the message goes out to it. this.finish_new_session( summary_at("created", "New Task", "/work/alpha"), generation, @@ -1759,6 +2087,7 @@ mod state_tests { assert!(!this.session_setup_pending); assert!(this.pending_first_send.is_none()); assert_eq!(this.selected_session.as_deref(), Some("created")); + assert_eq!(this.host_of("created"), this.target_host); assert!(this.awaiting_first_token, "the send was dispatched"); }); } @@ -2440,8 +2769,7 @@ mod state_tests { std::env::set_var("XDG_CONFIG_HOME", dir.join("config")); std::env::set_var("XDG_DATA_HOME", dir.join("data")); } - let backend = - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()); + let backend = crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()); unsafe { match previous_config { Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), @@ -2454,12 +2782,13 @@ mod state_tests { } Arc::new(backend.expect("backend")) }; - // The save runs on the backend's own runtime; wait for it here. + // The servers belong to the local host; the save runs on the + // backend's own runtime, so wait for it here. let (sender, receiver) = std::sync::mpsc::channel(); { - let backend = backend.clone(); - backend.clone().spawn(async move { - let _ = sender.send(backend.save_mcp_servers("user", servers).await); + let host = backend.local_host("user"); + backend.spawn(async move { + let _ = sender.send(host.save_mcp_servers(servers).await); }); } receiver @@ -2704,47 +3033,400 @@ mod state_tests { }); } - /// An empty task persisted by an older build is not opened by the - /// boot auto-select: the new-task screen stands in for it. + /// An empty task persisted by an older build is neither opened by the + /// boot auto-select nor shown as an open task: with one selected the + /// header offers the target chip, and a host switch leaves it behind. + #[gpui::test] + fn test_an_empty_task_counts_as_a_draft(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.project_root = Some("/work/local".to_string()); + this.selected_session = None; + + // The auto-select skips the empty task and starts a draft. + let mut empty = summary_at("blank", "New Task", "/work/local"); + empty.message_count = 0; + let requested = this.selection_generation; + this.apply_session_list(vec![empty.clone()], requested, cx); + assert_eq!(this.selected_session, None); + assert!(this.draft, "the fallback is a draft like any other"); + + // Opened by hand, it still reads as a draft. + this.selected_session = Some("blank".to_string()); + this.replace_timeline(Vec::new()); + assert!(this.selection_is_draft()); + assert_eq!( + this.selected_host, None, + "the header offers the target chip" + ); + + // A host switch leaves the empty task behind; what shows is a + // draft, so the new host's list cannot open a task over it. + this.pick_host(remote.clone(), cx); + assert_eq!(this.target_host, remote); + assert_eq!(this.selected_session, None); + assert!(this.draft, "the screen after the switch is a draft"); + let generation = this.selection_generation; + this.apply_session_list(vec![empty], generation, cx); + assert_eq!(this.selected_session, None); + assert_eq!(this.loading_session, None); + + // On a fresh screen (the draft left), a task with a message + // under the same root still opens. + this.clear_selected_session_presentation(cx); + assert!(!this.draft); + this.pick_host(HostId::local(), cx); + // The switch adopts the host's saved project (none here). + this.project_root = Some("/work/local".to_string()); + let generation = this.selection_generation; + this.apply_session_list( + vec![ + summary_at("blank", "New Task", "/work/local"), + summary_at("real", "Real", "/work/local"), + ] + .into_iter() + .map(|mut summary| { + if summary.id == "blank" { + summary.message_count = 0; + } + summary + }) + .collect(), + generation, + cx, + ); + assert_eq!(this.loading_session.as_deref(), Some("real")); + }); + } + + /// A remembered host that stays offline releases startup to the local + /// auto-select instead of holding the window empty. + #[gpui::test] + fn test_offline_restore_host_releases_startup(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + this.restore_host = Some(remote.clone()); + this.selected_session = None; + this.set_remote_host_status( + remote, + "Box".to_string(), + HostStatus::Offline { + reason: "unreachable".to_string(), + }, + None, + cx, + ); + assert_eq!(this.restore_host, None); + assert!(this.target_host.is_local()); + }); + } + + /// A host's bootstrap and task list carry the connection they were + /// read on. An answer that lands after the host dropped or came back + /// is stale and changes nothing: the new connection reads it afresh. + #[gpui::test] + fn test_answers_from_an_earlier_connection_are_dropped(cx: &mut TestAppContext) { + use maple_agent::host::HostSessionDefaults; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + this.set_remote_host_status( + remote.clone(), + "Box".to_string(), + HostStatus::Online, + Some(backend), + cx, + ); + let current = this.hosts[&remote].connection; + let boot = |title: &str| HostBootstrap { + project_root: Some("/work/remote".to_string()), + sessions: vec![summary_at("r1", title, "/work/remote")], + recent_roots: vec!["/work/remote".to_string()], + latest: None, + session_defaults: HostSessionDefaults::default(), + }; + let stale = RemoteBootstrap { + boot: boot("Stale"), + start_error: None, + summaries: HashMap::new(), + }; + this.finish_remote_bootstrap(remote.clone(), current - 1, Ok(stale), cx); + assert!(this.sessions.iter().all(|session| session.id != "r1")); + this.apply_listed_sessions( + &remote, + current - 1, + this.selection_generation, + Ok(vec![summary_at("r1", "Stale", "/work/remote")]), + cx, + ); + assert!(this.sessions.iter().all(|session| session.id != "r1")); + + let fresh = RemoteBootstrap { + boot: boot("Fresh"), + start_error: None, + summaries: HashMap::new(), + }; + this.finish_remote_bootstrap(remote.clone(), current, Ok(fresh), cx); + assert_eq!( + this.sessions + .iter() + .find(|session| session.id == "r1") + .map(|session| session.title.as_str()), + Some("Fresh") + ); + assert_eq!(this.host_of("r1"), remote); + }); + } + + /// The project chosen while a host is the target is what comes back + /// when that host is the target again, not what its bootstrap said. + #[gpui::test] + fn test_target_switch_keeps_each_hosts_newest_project(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.selected_session = None; + this.trust_prompts = false; + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(backend); + entry.project_root = Some("/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + if let Some(local) = this.hosts.get_mut(&HostId::local()) { + local.project_root = Some("/work/first".to_string()); + } + this.set_project_context(Some("/work/first".to_string()), cx); + // The user picks another project on the local host. + this.set_project_context(Some("/work/second".to_string()), cx); + assert_eq!( + this.hosts[&HostId::local()].project_root.as_deref(), + Some("/work/second") + ); + + this.pick_host(remote.clone(), cx); + assert_eq!(this.project_root.as_deref(), Some("/work/remote")); + this.pick_host(HostId::local(), cx); + assert_eq!(this.project_root.as_deref(), Some("/work/second")); + }); + } + + /// A remembered host that connects but fails its bootstrap will not + /// open its task either; startup goes on with the local auto-select. + #[gpui::test] + fn test_failed_bootstrap_releases_startup(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + this.hosts.insert(remote.clone(), ChatHost::local(backend)); + this.restore_host = Some(remote.clone()); + this.selected_session = None; + this.finish_remote_bootstrap(remote, 0, Err("no runtime".to_string()), cx); + assert_eq!(this.restore_host, None); + assert!(this.target_host.is_local()); + assert!( + this.notice + .as_deref() + .is_some_and(|n| n.contains("no runtime")) + ); + }); + } + + /// An offline host cannot take new tasks, so neither the filter nor + /// the header chip may make it the target; the filter that stands + /// is what the sidebar shows. + #[gpui::test] + fn test_offline_host_cannot_become_the_target(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + this.hosts + .insert(remote.clone(), ChatHost::saved("Box".to_string())); + this.hosts_changed(); + this.sync_sidebar(cx); + // The sidebar refuses the row itself. + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_host_filter(Some(remote.clone()), cx); + assert_eq!(sidebar.host_filter(), None); + }); + // And the screen refuses a filter that reached it anyway. + this.set_host_filter(Some(remote.clone()), cx); + assert!(this.target_host.is_local()); + assert_eq!(this.host_filter, None); + assert!( + this.notice + .as_deref() + .is_some_and(|n| n.contains("offline")) + ); + this.notice = None; + this.pick_host(remote, cx); + assert!(this.target_host.is_local()); + assert!(this.notice.is_some()); + }); + } + + /// When the target host drops, new tasks go to the local host, the + /// sidebar's filter follows, the header shows the local project, and + /// only the drop itself raises a notice: the reconnect attempts that + /// follow report nothing new. + #[gpui::test] + fn test_target_host_drop_falls_back_to_local(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(backend); + entry.name = "Box".to_string(); + entry.project_root = Some("/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + if let Some(local) = this.hosts.get_mut(&HostId::local()) { + local.project_root = Some("/work/local".to_string()); + } + this.selected_session = None; + this.hosts_changed(); + this.sync_sidebar(cx); + // As after a click on the sidebar's host row. + this.sidebar.update(cx, |sidebar, cx| { + sidebar.show_host_filter(Some(remote.clone()), cx); + }); + this.set_host_filter(Some(remote.clone()), cx); + assert_eq!(this.target_host, remote); + assert_eq!(this.sidebar.read(cx).host_filter(), Some(&remote)); + assert_eq!(this.project_root.as_deref(), Some("/work/remote")); + + this.set_remote_host_status( + remote.clone(), + "Box".to_string(), + HostStatus::Offline { + reason: "connection lost".to_string(), + }, + None, + cx, + ); + assert!(this.target_host.is_local()); + assert_eq!(this.host_filter, None); + assert_eq!(this.sidebar.read(cx).host_filter(), None); + assert_eq!(this.project_root.as_deref(), Some("/work/local")); + assert!( + this.notice + .as_deref() + .is_some_and(|n| n.contains("connection lost")) + ); + + // A failed reconnect is not news. + this.notice = None; + this.set_remote_host_status( + remote.clone(), + "Box".to_string(), + HostStatus::Connecting, + None, + cx, + ); + this.set_remote_host_status( + remote, + "Box".to_string(), + HostStatus::Offline { + reason: "unreachable".to_string(), + }, + None, + cx, + ); + assert_eq!(this.notice, None); + }); + } + + /// The task on screen keeps its title when its host drops, though its + /// row leaves the list until the host is back. + #[gpui::test] + fn test_selected_task_keeps_its_title_when_its_host_drops(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + this.hosts.insert(remote.clone(), ChatHost::local(backend)); + this.apply_host_session_list( + &remote, + vec![summary_at("r1", "Remote", "/work/remote")], + cx, + ); + this.set_active_session( + summary_at("r1", "Remote", "/work/remote"), + vec![user_item("u1", "hi")], + HashMap::new(), + cx, + ); + assert_eq!(this.selected_title.as_ref(), "Remote"); + + this.set_remote_host_status( + remote, + "Box".to_string(), + HostStatus::Offline { + reason: "connection lost".to_string(), + }, + None, + cx, + ); + assert!(this.sessions.iter().all(|session| session.id != "r1")); + assert_eq!(this.selected_session.as_deref(), Some("r1")); + assert_eq!(this.selected_title.as_ref(), "Remote"); + }); + } + + /// The header names what the pane shows: an empty pane is a new task + /// even while the list still marks a row. + #[gpui::test] + fn test_header_says_new_task_while_the_pane_is_empty(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, _cx| { + this.sessions = vec![summary_at("s1", "Hello", "/work/alpha")]; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(Vec::new()); + assert_eq!(this.selected_title.as_ref(), "New Task"); + + this.replace_timeline(vec![user_item("u1", "hi")]); + assert_eq!(this.selected_title.as_ref(), "Hello"); + + this.replace_timeline(Vec::new()); + assert_eq!(this.selected_title.as_ref(), "New Task"); + }); + } + + /// While a project selection is landing, neither "New Task" nor a + /// first send may run ahead of it. #[gpui::test] - fn test_auto_select_skips_an_empty_task(cx: &mut TestAppContext) { + fn test_new_task_waits_for_a_project_selection(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { this.project_root = Some("/work/alpha".to_string()); - this.selected_session = None; - let mut empty = summary_at("blank", "New Task", "/work/alpha"); - empty.message_count = 0; - let requested = this.selection_generation; - - this.apply_session_list(vec![empty], requested, cx); + this.root_selecting = true; + let generation = this.selection_generation; + this.new_session(cx); + assert_eq!(this.selection_generation, generation); + assert_eq!(this.selected_session.as_deref(), Some("s1")); + assert!(this.notice.is_some()); - assert_eq!(this.selected_session, None); + this.booting = false; + this.selected_session = None; + this.send_text("hello".to_string(), cx); assert!(!this.session_setup_pending); - assert_eq!(this.selected_title.as_ref(), "New Task"); - assert!(this.draft, "the fallback is a draft like any other"); - - // On a fresh screen (the draft left), a task with a message - // under the same root still opens. - this.clear_selected_session_presentation(cx); - this.selection_generation = requested; - this.apply_session_list( - vec![ - summary_at("blank", "New Task", "/work/alpha"), - summary_at("real", "Real", "/work/alpha"), - ] - .into_iter() - .map(|mut summary| { - if summary.id == "blank" { - summary.message_count = 0; - } - summary - }) - .collect(), - requested, - cx, - ); - assert_eq!(this.loading_session.as_deref(), Some("real")); + assert!(this.pending_first_send.is_none()); }); } @@ -2774,7 +3456,7 @@ mod state_tests { assert_eq!(this.sessions.len(), 1, "the list still updates"); this.apply_bootstrap( - crate::backend::LocalBootstrap { + HostBootstrap { project_root: Some("/work/alpha".to_string()), sessions: vec![old.clone()], recent_roots: Vec::new(), @@ -2787,6 +3469,7 @@ mod state_tests { items: Vec::new(), }, }), + session_defaults: maple_agent::host::HostSessionDefaults::default(), }, HashMap::new(), cx, @@ -2879,125 +3562,84 @@ mod state_tests { } #[gpui::test] - fn test_second_picker_click_is_ignored(cx: &mut TestAppContext) { + fn test_project_picker_rows_follow_the_query(cx: &mut TestAppContext) { + use crate::ui::chat::picker::{PickerRowKind, ProjectPicker}; cx.executor().allow_parking(); let screen = screen(cx); - screen.update(cx, |this, cx| { - this.popup.open(ChatPopup::Project, cx); - assert!(this.begin_root_picker(cx)); - assert!(this.root_picker_open); - assert!(!this.popup.is_open(&ChatPopup::Project)); - // A second click while the picker is open must not start another. - this.popup.open(ChatPopup::Project, cx); - assert!(!this.begin_root_picker(cx)); - assert!(this.popup.is_open(&ChatPopup::Project)); - }); - } - - #[test] - fn test_git_branch_reads_head_and_worktree_pointer() { - struct TempDir(std::path::PathBuf); - impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - let branch = |root: &std::path::Path| git_dir(root).as_deref().and_then(git_branch); - let guard = - TempDir(std::env::temp_dir().join(format!("maple-branch-{}", std::process::id()))); - let dir = &guard.0; - let repo = dir.join("repo"); - std::fs::create_dir_all(repo.join(".git")).unwrap(); - std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/feature/x\n").unwrap(); - assert_eq!(branch(&repo).as_deref(), Some("feature/x")); - - std::fs::write(repo.join(".git/HEAD"), "0123456789abcdef\n").unwrap(); - assert_eq!(branch(&repo).as_deref(), Some("0123456")); - - std::fs::write(repo.join(".git/HEAD"), "garbage-héad\n").unwrap(); - assert_eq!(branch(&repo), None); - std::fs::write(repo.join(".git/HEAD"), "0123456789abcdef\n").unwrap(); - - let worktree = dir.join("wt"); - std::fs::create_dir_all(&worktree).unwrap(); - std::fs::write( - worktree.join(".git"), - format!("gitdir: {}\n", repo.join(".git").display()), - ) - .unwrap(); - assert_eq!(branch(&worktree).as_deref(), Some("0123456")); - - let plain = dir.join("plain"); - std::fs::create_dir_all(&plain).unwrap(); - assert_eq!(branch(&plain), None); - } - - /// Issue #945: the branch watcher must ignore access-only HEAD events - /// (open, read, close). The branch read they trigger emits those same - /// events again under Linux inotify, looping at ~200% CPU while idle. - #[test] - fn test_head_watch_ignores_read_access_events() { - use notify::EventKind; - use notify::event::{ - AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind, - RemoveKind, RenameMode, + let kinds = |picker: &ProjectPicker| { + picker + .rows + .iter() + .map(|row| (row.kind, row.path.clone())) + .collect::>() }; + screen.update(cx, |this, cx| { + this.recent_roots = vec!["/home/me/alpha".to_string(), "/home/me/beta".to_string()]; + this.project_root = Some("/home/me/beta".to_string()); + this.open_project_picker(cx); + assert!( + this.root_input_focus_pending, + "typing must land in the picker" + ); + let picker = this.project_picker.as_ref().expect("picker open"); + // Recent projects first, the current one highlighted. + assert_eq!( + kinds(picker), + vec![ + (PickerRowKind::Recent, "/home/me/alpha".to_string()), + (PickerRowKind::Recent, "/home/me/beta".to_string()), + ] + ); + assert_eq!(picker.selected, 1); - let head = std::path::PathBuf::from("/repo/.git/HEAD"); - let event = |kind: EventKind| notify::Event::new(kind).add_path(head.clone()); - - // The read side of the loop, as emitted by Linux inotify. - for kind in [ - EventKind::Access(AccessKind::Open(AccessMode::Read)), - EventKind::Access(AccessKind::Read), - EventKind::Access(AccessKind::Close(AccessMode::Read)), - EventKind::Access(AccessKind::Close(AccessMode::Write)), - EventKind::Access(AccessKind::Any), - EventKind::Access(AccessKind::Other), - ] { - assert!(!head_change_event(&event(kind)), "access {kind:?}"); - } - - // Real changes still refresh the label: in-place write, create, - // remove, and the rename pair of an atomic replacement, plus the - // unclassified kinds imprecise backends emit for real changes. - for kind in [ - EventKind::Modify(ModifyKind::Data(DataChange::Content)), - EventKind::Modify(ModifyKind::Any), - EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)), - EventKind::Modify(ModifyKind::Name(RenameMode::From)), - EventKind::Modify(ModifyKind::Name(RenameMode::To)), - EventKind::Modify(ModifyKind::Name(RenameMode::Both)), - EventKind::Modify(ModifyKind::Name(RenameMode::Any)), - EventKind::Create(CreateKind::File), - EventKind::Create(CreateKind::Any), - EventKind::Remove(RemoveKind::File), - EventKind::Remove(RemoveKind::Any), - EventKind::Any, - EventKind::Other, - ] { - assert!(head_change_event(&event(kind)), "change {kind:?}"); - } + // A typed path is offered as is, above what matched it, and a + // new query starts at the top. + this.refresh_project_picker("/srv/work/".to_string(), cx); + let picker = this.project_picker.as_ref().expect("picker open"); + assert_eq!( + kinds(picker), + vec![(PickerRowKind::OpenPath, "/srv/work".to_string())] + ); + assert_eq!(picker.selected, 0); - // Unrelated paths never refresh, even with a change kind. - let unrelated = - notify::Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) - .add_path(std::path::PathBuf::from("/repo/.git/index")); - assert!(!head_change_event(&unrelated)); + // Plain text filters the recents. + this.refresh_project_picker("BETA".to_string(), cx); + let picker = this.project_picker.as_ref().expect("picker open"); + assert_eq!( + kinds(picker), + vec![(PickerRowKind::Recent, "/home/me/beta".to_string())] + ); - // A required rescan refreshes even without a HEAD path. - let event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan); - assert!(head_change_event(&event)); + // Arrows wrap; Escape closes and hands the keyboard back. + this.refresh_project_picker("home".to_string(), cx); + this.project_picker_move(-1, cx); + assert_eq!(this.project_picker.as_ref().map(|p| p.selected), Some(1)); + this.project_picker_move(1, cx); + assert_eq!(this.project_picker.as_ref().map(|p| p.selected), Some(0)); + this.screen_focus_pending = false; + this.escape(cx); + assert!(this.project_picker.is_none()); + assert!(this.screen_focus_pending); + }); } + /// The project chip and its shortcut open the picker, and close it + /// again while it is open. #[gpui::test] - fn test_escape_closes_root_menu(cx: &mut TestAppContext) { + fn test_choose_project_toggles_the_picker(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { - this.popup.open(ChatPopup::Project, cx); - this.escape(cx); - assert_eq!(this.popup.open_key(), None); + this.popup.open(ChatPopup::Model, cx); + this.toggle_project_picker(cx); + assert!(this.project_picker.is_some()); + assert_eq!( + this.popup.open_key(), + None, + "the picker closes the chip menus" + ); + this.toggle_project_picker(cx); + assert!(this.project_picker.is_none()); }); } @@ -3013,17 +3655,23 @@ mod state_tests { mode: None, active_runs: HashMap::new(), }; - assert!( - !this.apply_service_event(AgentServiceEvent::RuntimeStatus(status.clone()), cx) - ); - assert!( - this.apply_service_event(AgentServiceEvent::SessionCreated(summary("s1", "A")), cx) - ); - assert!( - !this - .apply_service_event(AgentServiceEvent::SessionCreated(summary("s1", "A")), cx) - ); + assert!(!this.apply_service_event( + &HostId::local(), + AgentServiceEvent::RuntimeStatus(status.clone()), + cx + )); + assert!(this.apply_service_event( + &HostId::local(), + AgentServiceEvent::SessionCreated(summary("s1", "A")), + cx + )); + assert!(!this.apply_service_event( + &HostId::local(), + AgentServiceEvent::SessionCreated(summary("s1", "A")), + cx + )); assert!(this.apply_service_event( + &HostId::local(), AgentServiceEvent::SessionCreated(summary("s1", "A renamed")), cx )); @@ -3562,6 +4210,7 @@ mod state_tests { let mut session = summary("s2", "Old"); session.updated_ms = 20; assert!(this.apply_service_event( + &HostId::local(), AgentServiceEvent::SessionUpdated { session_id: "s2".to_string(), run_id: None, @@ -3861,9 +4510,18 @@ mod state_tests { /// down and the button's click opened it again. #[gpui::test] fn test_a_second_press_on_each_trigger_closes_its_menu(cx: &mut TestAppContext) { + // A second host puts the host chip in the header; the new-task + // screen shows it as a menu. let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.models = vec!["voxtral-small-24b".to_string()]; + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote, entry); + this.hosts_changed(); + this.selected_session = None; this.sync_sidebar(cx); }); for (trigger, menu) in [ @@ -3872,7 +4530,7 @@ mod state_tests { ("model-picker", "Model"), ("permission-mode-toggle", "Mode"), ("mcp-menu", "Integrations"), - ("root-picker", "Project"), + ("host-picker", "Host"), ] { press(cx, trigger); assert_eq!(open_menu(&chat, cx), menu, "{trigger} opens its menu"); @@ -3896,9 +4554,18 @@ mod state_tests { /// menu, whichever view owns either of them. #[gpui::test] fn test_opening_a_menu_closes_the_open_one(cx: &mut TestAppContext) { + // A second host puts the host chip in the header; the new-task + // screen shows it as a menu. let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.models = vec!["voxtral-small-24b".to_string()]; + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote, entry); + this.hosts_changed(); + this.selected_session = None; this.sync_sidebar(cx); }); for (first, then, open) in [ @@ -3907,7 +4574,8 @@ mod state_tests { ("model-picker", "menu-session-s1", "Task(s1)"), ("model-picker", "projects-header", "Switcher"), ("menu-session-s1", "projects-header", "Switcher"), - ("root-picker", "permission-mode-toggle", "Mode"), + ("host-picker", "permission-mode-toggle", "Mode"), + ("permission-mode-toggle", "host-picker", "Host"), ] { press(cx, first); press(cx, then); @@ -3922,8 +4590,15 @@ mod state_tests { // A menu opened without the pointer closes the open one too: it // takes the focus. press(cx, "menu-session-s1"); + chat.update(cx, |this, cx| this.popup.open(ChatPopup::Host, cx)); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), "Host"); + + // So does the project picker, a dialog with a search box. cx.simulate_keystrokes("secondary-p"); - assert_eq!(open_menu(&chat, cx), "Project"); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), ""); + assert!(chat.update(cx, |this, _| this.project_picker.is_some())); } /// A task menu reaches over the rows below it. A press on one of its @@ -3958,51 +4633,34 @@ mod state_tests { ); } - /// The header's project menu closes on a press outside it, like every - /// other menu. It used to stay open. - #[gpui::test] - fn test_the_project_menu_closes_on_an_outside_press(cx: &mut TestAppContext) { - let (chat, cx) = chat_window(cx, |this, cx| { - this.recent_roots = vec![absolute_fixture_root("one")]; - this.sync_sidebar(cx); - }); - press(cx, "root-picker"); - assert_eq!(open_menu(&chat, cx), "Project"); - cx.simulate_click(gpui::point(px(900.), px(500.)), gpui::Modifiers::default()); - assert_eq!(open_menu(&chat, cx), ""); - } - - /// Typing into the project menu's path field stays in the field, and - /// Enter applies the path. Typing used to jump to the composer. + /// Typing into the project picker's search box stays in the box. + /// Typing used to jump to the composer. #[gpui::test] - fn test_the_project_path_field_keeps_its_typing(cx: &mut TestAppContext) { + fn test_the_project_picker_field_keeps_its_typing(cx: &mut TestAppContext) { let (chat, cx) = chat_window(cx, |_, _| {}); - chat.update(cx, |this, cx| this.show_root_input(cx)); + chat.update(cx, |this, cx| this.open_project_picker(cx)); cx.run_until_parked(); - let input = chat.update(cx, |this, _| this.root_input.clone().expect("path field")); + let input = chat.update(cx, |this, _| this.root_input.clone().expect("search box")); let handle = cx.update(|_, app| input.read(app).focus_handle(app)); assert_eq!( cx.update(|window, app| window.focused(app)), Some(handle), - "the path field takes the focus when it is offered" + "the search box takes the focus when the picker opens" ); cx.simulate_input("relative"); assert_eq!(input.update(cx, |input, _| input.text()), "relative"); let composer = chat.update(cx, |this, _| this.composer.clone().expect("composer")); assert_eq!(composer.update(cx, |composer, _| composer.text()), ""); - assert_eq!(open_menu(&chat, cx), "Project", "the menu stays open"); - cx.simulate_keystrokes("enter"); - assert_eq!( - chat.update(cx, |this, _| this.notice.clone()), - Some("Enter an absolute directory path".into()), - "Enter applies what was typed" + assert!( + chat.update(cx, |this, _| this.project_picker.is_some()), + "the picker stays open" ); } - /// Escape in the path field closes the project menu and hands the - /// keyboard back to where it was. + /// Escape in the search box closes the picker and hands the keyboard + /// back to where it was. #[gpui::test] - fn test_escape_in_the_path_field_closes_the_project_menu(cx: &mut TestAppContext) { + fn test_escape_in_the_search_box_closes_the_picker(cx: &mut TestAppContext) { let (chat, cx) = chat_window(cx, |_, _| {}); let composer = chat.update(cx, |this, cx| { this.composer @@ -4012,55 +4670,26 @@ mod state_tests { .focus_handle(cx) }); cx.update(|window, app| window.focus(&composer, app)); - chat.update(cx, |this, cx| this.show_root_input(cx)); + chat.update(cx, |this, cx| this.open_project_picker(cx)); cx.run_until_parked(); cx.simulate_keystrokes("escape"); - assert_eq!(open_menu(&chat, cx), ""); + cx.run_until_parked(); + assert!(chat.update(cx, |this, _| this.project_picker.is_none())); assert_eq!(cx.update(|window, app| window.focused(app)), Some(composer)); } - /// Under Application Vim, `g g` belongs to the menu itself, not to a - /// field inside it: "logging" typed into the path field keeps every - /// letter. + /// Under Application Vim, `g g` belongs to the screen, not to the + /// picker's search box: "logging" typed there keeps every letter. #[gpui::test] - fn test_application_vim_leaves_a_field_in_a_menu_its_letters(cx: &mut TestAppContext) { + fn test_application_vim_leaves_the_search_box_its_letters(cx: &mut TestAppContext) { let (chat, cx) = chat_window(cx, |this, cx| this.set_application_vim_enabled(true, cx)); - chat.update(cx, |this, cx| this.show_root_input(cx)); + chat.update(cx, |this, cx| this.open_project_picker(cx)); cx.run_until_parked(); cx.simulate_keystrokes("l o g g i n g"); - let input = chat.update(cx, |this, _| this.root_input.clone().expect("path field")); + let input = chat.update(cx, |this, _| this.root_input.clone().expect("search box")); assert_eq!(input.update(cx, |input, _| input.text()), "logging"); } - /// The path field's own right-click menu opens inside the project menu - /// and reaches past its edge. Its rows take their presses, and the - /// project menu stays open. - #[gpui::test] - fn test_a_fields_menu_inside_a_menu_takes_its_presses(cx: &mut TestAppContext) { - let (chat, cx) = chat_window(cx, |_, _| {}); - chat.update(cx, |this, cx| this.show_root_input(cx)); - cx.run_until_parked(); - cx.simulate_input("/tmp/somewhere"); - let input = chat.update(cx, |this, _| this.root_input.clone().expect("path field")); - let field = cx.debug_bounds("root-path-field").expect("path field"); - let project_menu = cx.debug_bounds("project-menu").expect("project menu"); - for item in ["text-input-select-all", "text-input-cut"] { - let at = field.center(); - cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); - cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default()); - let row = cx.debug_bounds(item).expect("the field's menu row"); - if item == "text-input-select-all" { - assert!( - !project_menu.contains(&row.center()), - "the fixture puts Select all past the project menu's edge" - ); - } - cx.simulate_click(row.center(), gpui::Modifiers::default()); - assert_eq!(open_menu(&chat, cx), "Project", "after {item}"); - } - assert_eq!(input.update(cx, |input, _| input.text()), ""); - } - /// A project's rename field sits in the switcher. Escape ends the /// rename and Enter commits it; either way the switcher stays open and /// has the keyboard again. @@ -4517,42 +5146,18 @@ mod state_tests { /// None mode here would restart every task at Ask First. #[gpui::test] fn test_new_task_takes_the_saved_permission_default(cx: &mut TestAppContext) { - let _guard = SETTINGS_LOCK.lock(); - let dir = std::env::temp_dir().join(format!( - "maple-agent-test-permission-default-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - let config = dir.join("maple-agent"); - std::fs::create_dir_all(&config).unwrap(); - std::fs::write( - config.join("settings.json"), - r#"{"default_permission_mode":"auto"}"#, - ) - .unwrap(); - let previous = std::env::var_os("XDG_CONFIG_HOME"); - unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) }; cx.executor().allow_parking(); - let screen = cx.new(|cx| { - ChatScreen::new_inner( - std::sync::Arc::new( - crate::backend::AgentBackend::new( - "http://127.0.0.1:9".to_string(), - String::new(), - ) - .expect("backend"), - ), - "user".to_string(), + let screen = screen(cx); + screen.update(cx, |this, cx| { + // The host's bootstrap carries its saved default. + this.apply_session_defaults( + &maple_agent::host::HostSessionDefaults { + permission_mode: "auto".to_string(), + ..Default::default() + }, cx, - ) - }); - match previous { - Some(value) => unsafe { std::env::set_var("XDG_CONFIG_HOME", value) }, - None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, - } - let _ = std::fs::remove_dir_all(&dir); - - screen.update(cx, |this, _cx| { + ); + assert_eq!(this.permission_mode, PermissionMode::Auto); this.project_root = Some("/work/beta".to_string()); let request = this.new_session_request().expect("explicit root request"); assert_eq!( @@ -4711,6 +5316,7 @@ mod state_tests { assert!(!this.active_runs.contains_key("s2")); this.apply_service_event( + &HostId::local(), AgentServiceEvent::RuntimeStatus(AgentRuntimeStatus { running: true, project_root: Some("/work/alpha".to_string()), @@ -4843,10 +5449,15 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); - let mut this = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut this = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); this.selected_session = Some("s1".to_string()); this.sessions = (0..200) .map(|n| summary(&format!("s{n}"), &format!("Task {n}"))) @@ -4952,10 +5563,15 @@ mod state_tests { let chat = cx.new(move |cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); - let mut this = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut this = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); this.selected_session = Some("s1".to_string()); this.application_vim_enabled = application_vim_enabled; this.application_focus = application_vim_enabled.then(|| cx.focus_handle()); @@ -5073,6 +5689,110 @@ mod state_tests { ); } + /// With the project picker open, typing goes to its search box and + /// never to the composer behind it, whatever held focus before. + #[gpui::test] + fn test_typing_with_the_project_picker_open_lands_in_its_search(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + struct ChatHost { + chat: Entity, + } + impl Render for ChatHost { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + div().w(px(1200.)).h(px(800.)).child(self.chat.clone()) + } + } + + let chat = cx.new(|cx| { + let _guard = SETTINGS_LOCK.lock(); + let backend = std::sync::Arc::new( + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) + .expect("backend"), + ); + let host = backend.local_host("user"); + // The arrows reach the input as bound actions, as in the app. + crate::desktop::register_key_bindings(cx); + // No bootstrap: the recent roots below must stay as set. + ChatScreen::new_without_start(backend, host, "user".to_string(), cx) + }); + chat.update(cx, |this, _cx| { + this.booting = false; + this.trust_prompts = false; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(vec![user_item("u1", "hello")]); + this.recent_roots = vec!["/home/me/alpha".to_string(), "/home/me/beta".to_string()]; + }); + + let (_host, cx) = cx.add_window_view(|_window, _cx| ChatHost { chat: chat.clone() }); + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + + // Start from the transcript, as after a text-selection press. + let transcript_focus = + cx.update(|_window, app| chat.read(app).transcript_focus.clone().unwrap()); + cx.update(|window, app| window.focus(&transcript_focus, app)); + cx.update(|_window, app| chat.update(app, |this, cx| this.open_project_picker(cx))); + cx.run_until_parked(); + + // Arrows move the highlight and fill the box with its path; the + // rows stay put while they do. + let picker_state = |cx: &mut gpui::VisualTestContext| { + cx.update(|_window, app| { + let this = chat.read(app); + let picker = this.project_picker.as_ref().expect("picker open"); + ( + picker.selected, + picker.rows.len(), + this.root_input + .as_ref() + .unwrap() + .read(app) + .text() + .to_string(), + ) + }) + }; + let last_path = |cx: &mut gpui::VisualTestContext| { + cx.update(|_window, app| { + let this = chat.read(app); + let picker = this.project_picker.as_ref().expect("picker open"); + picker.rows.last().expect("rows").path.clone() + }) + }; + // The local host also lists this machine's home folders after + // the recents, so only the count's stability is asserted. + let rows = picker_state(cx).1; + cx.simulate_keystrokes("down"); + cx.run_until_parked(); + assert_eq!(picker_state(cx), (1, rows, "/home/me/beta".to_string())); + // Up from the top wraps to the last row. + cx.simulate_keystrokes("up up"); + cx.run_until_parked(); + assert_eq!(picker_state(cx), (rows - 1, rows, last_path(cx))); + cx.simulate_keystrokes("down"); + cx.run_until_parked(); + assert_eq!(picker_state(cx), (0, rows, "/home/me/alpha".to_string())); + + cx.simulate_input("src"); + cx.run_until_parked(); + cx.update(|_window, app| { + chat.update(app, |this, cx| { + assert_eq!( + this.root_input.as_ref().unwrap().read(cx).text(), + "/home/me/alphasrc" + ); + assert_eq!(this.composer.as_ref().unwrap().read(cx).text(), ""); + assert!( + this.project_picker.is_some(), + "typing keeps the picker open" + ); + }) + }); + } + /// Plain typing while the transcript holds focus must land in the /// composer, including the first character, without a click first. /// Chords and enter/tab keep their meaning instead of stealing focus. @@ -5097,10 +5817,11 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); - ChatScreen::new(backend, "user".to_string(), cx) + let host = backend.local_host("user"); + ChatScreen::new(backend, host, "user".to_string(), cx) }); chat.update(cx, |this, _cx| { // The real constructor bootstraps and may open the project-trust @@ -5186,11 +5907,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.application_vim_enabled = true; chat.application_focus = Some(cx.focus_handle()); @@ -5287,11 +6013,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.application_vim_enabled = false; @@ -5337,8 +6068,8 @@ mod state_tests { // Once no focus transition consumes Escape, the same central route // still reaches Chat's legacy menu-close behavior. - chat.update(cx, |this, cx| this.toggle_root_menu(cx)); - assert!(cx.update(|_window, app| { chat.read(app).popup.is_open(&ChatPopup::Project) })); + chat.update(cx, |this, cx| this.popup.open(ChatPopup::Model, cx)); + assert!(cx.update(|_window, app| chat.read(app).popup.is_open(&ChatPopup::Model))); cx.simulate_keystrokes("escape"); assert_eq!( cx.update(|_window, app| chat.read(app).popup.open_key().copied()), @@ -5380,11 +6111,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.application_vim_enabled = false; @@ -5551,11 +6287,11 @@ mod state_tests { }); } - /// Ctrl-P opens the project menu, takes the focus off the composer, - /// and walks its rows with plain arrow keys. Closing the menu gives - /// the composer its focus back. + /// Ctrl-P opens the project picker with the keyboard in its search + /// box, the arrows walk its rows, Enter opens the highlighted project, + /// and the composer takes the keyboard back when the picker closes. #[gpui::test] - fn test_project_menu_walks_with_arrows_and_application_vim_jk(cx: &mut TestAppContext) { + fn test_project_shortcut_opens_the_picker(cx: &mut TestAppContext) { cx.executor().allow_parking(); struct ChatHost { chat: Entity, @@ -5573,12 +6309,18 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); + chat.trust_prompts = false; chat.recent_roots = vec![absolute_fixture_root("one"), absolute_fixture_root("two")]; chat.booting = false; chat @@ -5591,55 +6333,53 @@ mod state_tests { cx.update(|window, app| window.focus(&composer_handle, app)); cx.simulate_keystrokes("secondary-p"); - assert!(cx.update(|_window, app| { chat.read(app).popup.is_open(&ChatPopup::Project) })); - assert_ne!( + cx.run_until_parked(); + let search_handle = cx.update(|_window, app| { + let this = chat.read(app); + assert!(this.project_picker.is_some()); + this.root_input.clone().unwrap().focus_handle(app) + }); + assert_eq!( cx.update(|window, app| window.focused(app)), - Some(composer_handle.clone()), - "the open menu must hold the focus, or the arrows type instead" + Some(search_handle), + "the open picker must hold the focus, or the arrows type instead" ); - // Two recent roots and the "New project…" row: down, down, down - // wraps back to the first. - let highlighted = |cx: &mut gpui::VisualTestContext| { - cx.update(|_window, app| chat.read(app).popup.highlighted().cloned()) - }; - let root = |name: &str| { - Some(gpui::ElementId::from(SharedString::from(format!( - "root-{}", - absolute_fixture_root(name) - )))) - }; - let choose = || Some(gpui::ElementId::from("root-choose")); - cx.simulate_keystrokes("down"); - assert_eq!(highlighted(cx), root("one")); - cx.simulate_keystrokes("down down"); - assert_eq!(highlighted(cx), choose()); + // Down highlights the second recent project and fills it in. cx.simulate_keystrokes("down"); - assert_eq!(highlighted(cx), root("one")); - cx.simulate_keystrokes("up"); - assert_eq!(highlighted(cx), choose()); + cx.run_until_parked(); + assert_eq!( + cx.update(|_window, app| chat.read(app).project_picker.as_ref().map(|p| p.selected)), + Some(1) + ); - // Enter on a recent root asks for the switch and closes the menu; - // the composer takes the typing back. - cx.simulate_keystrokes("up up"); - assert_eq!(highlighted(cx), root("one")); + // Enter asks for the switch and closes the picker; the composer + // takes the typing back. The host may have answered the switch + // already (the fixture path need not exist), in which case its + // answer is on screen instead of the switch in flight. cx.simulate_keystrokes("enter"); - assert!(!cx.update(|_window, app| { chat.read(app).popup.is_open(&ChatPopup::Project) })); + cx.run_until_parked(); + cx.update(|_window, app| { + let this = chat.read(app); + assert!(this.project_picker.is_none()); + assert!( + this.root_selecting || this.notice.is_some(), + "Enter opens the highlighted project" + ); + }); assert_eq!( cx.update(|window, app| window.focused(app)), Some(composer_handle), - "closing the menu must hand the focus back" + "closing the picker must hand the focus back" ); - // Application Vim adds its own context to the focused menu, so its - // j/k aliases are live without disturbing the legacy arrow bindings. - chat.update(cx, |this, cx| this.set_application_vim_enabled(true, cx)); + // The shortcut closes an open picker too. cx.simulate_keystrokes("secondary-p"); - assert!(cx.update(|_window, app| { chat.read(app).popup.is_open(&ChatPopup::Project) })); - cx.simulate_keystrokes("j j"); - assert_eq!(highlighted(cx), root("two")); - cx.simulate_keystrokes("k"); - assert_eq!(highlighted(cx), root("one")); + cx.run_until_parked(); + assert!(cx.update(|_window, app| chat.read(app).project_picker.is_some())); + cx.simulate_keystrokes("secondary-p"); + cx.run_until_parked(); + assert!(cx.update(|_window, app| chat.read(app).project_picker.is_none())); } /// The open composer menu (model picker and friends) floats above the @@ -5666,11 +6406,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.replace_timeline(vec![user_item("u1", "hello")]); @@ -5731,11 +6476,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.replace_timeline(vec![ diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index 16cad450a..ee1220400 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -414,8 +414,7 @@ mod tests { #[gpui::test] fn tab_reaches_password_and_enter_submits(cx: &mut TestAppContext) { cx.executor().allow_parking(); - let backend = - Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); + let backend = Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string()).unwrap()); let screen = cx.new(|cx| { crate::desktop::register_key_bindings(cx); LoginScreen::new(backend, cx) @@ -481,8 +480,7 @@ mod tests { #[gpui::test] fn oauth_committed_success_is_delivered_when_back_precedes_ui_receipt(cx: &mut TestAppContext) { cx.executor().allow_parking(); - let backend = - Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); + let backend = Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string()).unwrap()); let screen = cx.new(|cx| LoginScreen::new(backend, cx)); let signed_in = Rc::new(RefCell::new(Vec::new())); let observed = Rc::clone(&signed_in); diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..a9b67b69e 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -883,8 +883,6 @@ pub(crate) struct MenuItem { /// Replaces the label and note; the label still names the row for /// assistive technology and type-ahead. content: Option, - /// Elide a long label at its start, keeping the end (a path's folder). - truncate_start: bool, /// `Some` for a row that shows a state: a check for the current choice /// (a radio item) or a switch (a checkbox item). state: Option, @@ -913,7 +911,6 @@ impl MenuItem { icon: None, note: None, content: None, - truncate_start: false, state: None, enabled: true, keep_open: false, @@ -940,12 +937,6 @@ impl MenuItem { self } - /// Elide a long label at its start, so a path keeps its last folder. - pub(crate) fn truncate_start(mut self) -> Self { - self.truncate_start = true; - self - } - /// One choice of several; `current` shows the check mark. pub(crate) fn current(mut self, current: bool) -> Self { self.state = Some(ItemState::Current(current)); @@ -1008,7 +999,6 @@ fn item_row( icon: icon_name, note, content, - truncate_start, state, enabled, keep_open, @@ -1081,19 +1071,7 @@ fn item_row( .min_w_0() .flex() .flex_col() - .child( - div() - .min_w_0() - .line_clamp(1) - .map(|label| { - if truncate_start { - label.text_ellipsis_start() - } else { - label.text_ellipsis() - } - }) - .child(label), - ) + .child(div().min_w_0().line_clamp(1).text_ellipsis().child(label)) .children(note.map(|note| { div() .text_xs() diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 7a01cf701..ded142892 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -20,21 +20,29 @@ use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; use crate::backend::AgentBackend; -use crate::settings::{self, AppSettings, PermissionMode, UsageSummary}; +use crate::remote::host::{HostingController, HostingStatus}; +use crate::settings::{self, AppSettings, PermissionMode}; use crate::shortcuts::{ ShortcutConflict, ShortcutConflictKind, ShortcutContextOverlap, ShortcutOverrides, ShortcutSnapshot, }; use crate::ui::theme; use crate::ui::widgets; +use maple_agent::host::{HostBackend, HostId, HostSessionDefaults, UsageSummary}; +use maple_remote::devices::PairedDevice; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::{HostManager, HostVersion}; +use maple_remote::pairing::PairingCode; mod account; mod api_keys; mod billing; +mod hosts; mod navigation; use self::account::AccountState; use self::api_keys::ApiKeysState; use self::billing::BillingState; +use self::hosts::HostRow; use self::navigation::{GeneralTarget, SettingsApplicationVimState, SettingsTarget}; /// Emitted when the user leaves settings. @@ -56,10 +64,20 @@ pub enum Section { Shortcuts, Prompt, Integrations, + Hosts, Usage, About, } +/// One connected host the settings screen can point its host-scoped +/// sections at. +#[derive(Clone)] +pub struct SettingsHost { + pub id: HostId, + pub name: String, + pub backend: Arc, +} + impl Section { fn label(self) -> &'static str { match self { @@ -70,12 +88,13 @@ impl Section { Self::Shortcuts => "Keyboard Shortcuts", Self::Prompt => "System prompt", Self::Integrations => "Integrations", + Self::Hosts => "Hosts", Self::Usage => "Usage", Self::About => "About", } } - const ALL: [Self; 9] = [ + const ALL: [Self; 10] = [ Self::General, Self::Account, Self::Billing, @@ -83,15 +102,27 @@ impl Section { Self::Shortcuts, Self::Prompt, Self::Integrations, + Self::Hosts, Self::Usage, Self::About, ]; + + /// Sections whose content belongs to one host and follow the host + /// selector. + fn is_host_scoped(self) -> bool { + matches!( + self, + Self::General | Self::Prompt | Self::Integrations | Self::Usage + ) + } } /// One multi-value General row that selects from a dropdown instead of /// toggling. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SettingMenu { + /// Which connected host the host-scoped sections show. + Host, Permission, Appearance, ChatFont, @@ -104,6 +135,7 @@ impl SettingMenu { /// Stable id fragment for the popup panel and its rows. fn id(self) -> &'static str { match self { + Self::Host => "host", Self::Permission => "permission", Self::Appearance => "appearance", Self::ChatFont => "chat-font", @@ -124,8 +156,40 @@ pub struct SettingsScreen { /// [`crate::ui::task::call`]. bridged_tasks: std::cell::RefCell>>, backend: Arc, + /// The host whose integrations, MCP servers, usage, and session + /// defaults the host-scoped sections show. + host: Arc, + /// Every connected host, for the selector; local first. + hosts: Vec, + /// Pairs, renames, and removes hosts. Absent in tests. + manager: Option>, + /// This window's host role. Absent in tests. + hosting: Option>, + hosting_status: HostingStatus, + /// The pairing code this window published, shown until it expires. + pairing_code: Option<(String, u64)>, + /// Devices paired with this machine as a host. + paired_devices: Vec, + /// The saved host list the Hosts section shows. + saved_hosts: Vec, + /// `saved_hosts` as rendered: connection state, version, and how it + /// compares with this app. Rebuilt when the list or a host's state + /// changes, not in render. + host_rows: Vec, + /// Bumped each time the Hosts section starts polling the manager, so + /// a stale poll loop stops instead of running beside the new one. + hosts_watch: u64, + /// The add-host form. + host_address: Entity, + host_code: Entity, + host_name: Entity, + pairing: bool, + hosts_notice: Option, user_id: String, settings: AppSettings, + /// The host's defaults for new tasks; the built-in defaults until the + /// host answers. + defaults: HostSessionDefaults, /// `settings.theme` parsed once; render only reads the label. theme: theme::Preference, section: Section, @@ -236,15 +300,46 @@ impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl SettingsScreen { + #[allow(clippy::too_many_arguments)] pub fn new( backend: Arc, + host: Arc, + hosts: Vec, + manager: Option>, + hosting: Option>, user_id: String, settings: AppSettings, shortcut_snapshot: ShortcutSnapshot, section: Section, cx: &mut Context, ) -> Self { - let prompt_text = settings.effective_harness_instructions(); + let defaults = HostSessionDefaults::default(); + let hosting_status = hosting + .as_ref() + .map(|hosting| hosting.status()) + .unwrap_or(HostingStatus::Off); + let host_field = |placeholder: &str, index: isize, cx: &mut Context| { + let application_vim_enabled = settings.application_vim_enabled; + let placeholder = placeholder.to_string(); + cx.new(move |cx| { + TextInput::new(&placeholder, cx) + .with_tab_index(index) + .application_vim(application_vim_enabled) + }) + }; + let host_address = host_field("100.64.0.7:7130", 6, cx); + let host_code = host_field("XXXX-XXXX-XXXX-XXXX", 7, cx); + let host_name = host_field("Workstation (optional)", 8, cx); + let saved_hosts = manager + .as_ref() + .and_then(|manager| manager.store().list().ok()) + .unwrap_or_default(); + let host_rows = hosts::rows(&saved_hosts, &Self::app_version(), latest_release(), |id| { + manager + .as_ref() + .and_then(|manager| manager.host_version(id)) + }); + let prompt_text = defaults.effective_harness_instructions(); let application_vim_enabled = settings.application_vim_enabled; let application_focus = cx.focus_handle(); let prompt_application_focus = application_focus.clone(); @@ -283,12 +378,28 @@ impl SettingsScreen { let application_anchor = ScrollAnchor::for_handle(pane_scroll.clone()); let application_vim = SettingsApplicationVimState::new(section); let application_focus_pending = settings.application_vim_enabled; - let this = Self { + let mut this = Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, + host, + hosts, + manager, + hosting, + hosting_status, + pairing_code: None, + paired_devices: Vec::new(), + saved_hosts, + host_rows, + hosts_watch: 0, + host_address, + host_code, + host_name, + pairing: false, + hosts_notice: None, user_id, theme: theme::Preference::parse(&settings.theme), settings, + defaults, section, popup: Popup::new(|this| &mut this.popup, cx), account: AccountState::new(application_vim_enabled, application_focus.clone(), cx), @@ -323,13 +434,29 @@ impl SettingsScreen { this.load_account(cx); this.load_billing(cx); this.load_api_keys(cx); + this.load_session_defaults(cx); this.load_usage(cx); this.load_plan(cx); this.load_mcp_servers(cx); this.load_integrations(cx); + this.load_paired_devices(cx); + if matches!(this.hosting_status, HostingStatus::Starting) { + this.watch_hosting_start(cx); + } + if this.section == Section::Hosts { + this.watch_hosts(cx); + } this } + /// What this app would announce in its own hello. + fn app_version() -> HostVersion { + HostVersion { + version: crate::env::APP_VERSION.to_string(), + build: crate::env::build_hash().map(str::to_string), + } + } + fn load_plan(&self, cx: &mut Context) { let backend = self.backend.clone(); let user_id = self.user_id.clone(); @@ -359,10 +486,9 @@ impl SettingsScreen { } fn load_mcp_servers(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.list_mcp_servers(&user_id).await }, + async move { host.list_mcp_servers().await }, cx, |this, result, cx| { match result { @@ -378,10 +504,9 @@ impl SettingsScreen { } fn load_integrations(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.list_integrations(&user_id).await }, + async move { host.list_integrations().await }, cx, |this, result, cx| { match result { @@ -434,13 +559,11 @@ impl SettingsScreen { self.integration_notice = None; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let request_id = id.clone(); self.call( async move { - backend - .set_integration_enabled(&user_id, &request_id, enabled) + host.set_integration_enabled(request_id.clone(), enabled) .await }, cx, @@ -490,13 +613,14 @@ impl SettingsScreen { cx.notify(); let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let request_id = id.clone(); self.call( async move { backend - .setup_integration(&user_id, &request_id, permissions) - .await + .open_integration_setup_settings(&permissions) + .await?; + host.setup_integration(request_id).await }, cx, move |this, result, cx| { @@ -532,10 +656,9 @@ impl SettingsScreen { self.mcp_saving = true; self.mcp_notice = None; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.save_mcp_servers(&user_id, servers).await }, + async move { host.save_mcp_servers(servers).await }, cx, |this, result, cx| { this.mcp_saving = false; @@ -709,22 +832,727 @@ impl SettingsScreen { self.save_mcp_servers(servers, cx); } - fn load_usage(&self, cx: &mut Context) { - let (spawn_backend, usage_backend) = (self.backend.clone(), self.backend.clone()); + fn current_host_name(&self) -> String { + self.hosts + .iter() + .find(|host| host.id == *self.host.id()) + .map(|host| host.name.clone()) + .unwrap_or_else(|| "This computer".to_string()) + } + + /// Point the host-scoped sections at another connected host and + /// re-read everything they show. + fn select_host(&mut self, host: SettingsHost, cx: &mut Context) { + if host.id == *self.host.id() { + return; + } + self.host = host.backend; + self.mcp_servers = None; + self.mcp_editor = None; + self.integrations = None; + self.usage = None; + self.load_session_defaults(cx); + self.load_usage(cx); + self.load_mcp_servers(cx); + self.load_integrations(cx); + cx.notify(); + } + + /// Whether a host is connected right now. The manager is the live + /// answer; `self.hosts` is the snapshot taken at open and would keep a + /// host that dropped since looking online. + fn host_is_online(&self, id: &str) -> bool { + self.manager + .as_ref() + .is_some_and(|manager| manager.is_online(id)) + } + + /// What a host's live connection announced, or `None` while offline. + fn host_version(&self, id: &str) -> Option { + self.manager + .as_ref() + .and_then(|manager| manager.host_version(id)) + } + + /// Recompute the Hosts rows from the saved list and the manager's + /// live state. Returns whether anything shown changed. + fn rebuild_host_rows(&mut self) -> bool { + let rows = hosts::rows( + &self.saved_hosts, + &Self::app_version(), + latest_release(), + |id| self.host_version(id), + ); + if rows == self.host_rows { + return false; + } + self.host_rows = rows; + true + } + + /// Poll the manager once a second while the Hosts section is shown, + /// so a host that connects or drops updates its row; nothing pushes + /// status changes to this screen. Re-renders only when a row changed. + /// A flipped connection state re-reads the saved list too, since the + /// hello that just completed rewrote that host's last seen version. + fn watch_hosts(&mut self, cx: &mut Context) { + self.hosts_watch = self.hosts_watch.wrapping_add(1); + let generation = self.hosts_watch; + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(1)) + .await; + let keep_going = this.update(cx, |this, cx| { + if this.section != Section::Hosts || this.hosts_watch != generation { + return false; + } + let flipped = this + .host_rows + .iter() + .any(|row| this.host_is_online(&row.id) != row.online); + if flipped { + this.reload_saved_hosts(); + cx.notify(); + } else if this.rebuild_host_rows() { + cx.notify(); + } + true + }); + if !matches!(keep_going, Ok(true)) { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + + /// Re-render shortly, so a host that connects after pairing shows as + /// online without the user leaving the screen. + fn refresh_hosts_soon(&self, cx: &mut Context) { + let task = cx.spawn(async move |this, cx| { + for _ in 0..4 { + cx.background_executor() + .timer(std::time::Duration::from_millis(750)) + .await; + if this + .update(cx, |this, cx| { + this.reload_saved_hosts(); + cx.notify(); + }) + .is_err() + { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + + fn reload_saved_hosts(&mut self) { + if let Some(manager) = &self.manager + && let Ok(hosts) = manager.store().list() + { + self.saved_hosts = hosts; + } + self.rebuild_host_rows(); + } + + /// Pair with the host in the form. The manager saves it and connects; + /// it appears in the sidebar once online. + fn pair_host(&mut self, cx: &mut Context) { + if self.pairing { + return; + } + let Some(manager) = self.manager.clone() else { + self.hosts_notice = Some("Hosts are unavailable in this session.".to_string()); + cx.notify(); + return; + }; + let address = self.host_address.read(cx).text().trim().to_string(); + let code = match PairingCode::parse(&self.host_code.read(cx).text()) { + Ok(code) => code, + Err(message) => { + self.hosts_notice = Some(message); + cx.notify(); + return; + } + }; + if address.is_empty() { + self.hosts_notice = Some("Enter the host's address, like 100.64.0.7:7130.".to_string()); + cx.notify(); + return; + } + let name = + Some(self.host_name.read(cx).text().trim().to_string()).filter(|name| !name.is_empty()); + self.pairing = true; + self.hosts_notice = Some("Pairing…".to_string()); + cx.notify(); + self.call( + async move { manager.pair(&address, code, name).await }, + cx, + |this, result, cx| { + this.pairing = false; + match result { + Ok(host) => { + this.hosts_notice = Some(format!( + "Paired with {}. Its tasks appear in the sidebar once it is connected.", + host.name + )); + for input in [&this.host_address, &this.host_code, &this.host_name] { + input.update(cx, |input, cx| input.set_text("", cx)); + } + this.reload_saved_hosts(); + this.refresh_hosts_soon(cx); + } + Err(message) => this.hosts_notice = Some(message), + } + cx.notify(); + }, + ); + } + + fn remove_host(&mut self, id: &str, cx: &mut Context) { + let Some(manager) = &self.manager else { + return; + }; + self.hosts_notice = manager.remove(id).err(); + self.reload_saved_hosts(); + cx.notify(); + } + + /// Turn this window's host role on or off. Off persists at once. On + /// starts the host on the backend runtime and persists only once it + /// listens, so a failed start does not come back at the next launch. + fn toggle_remote_connections(&mut self, cx: &mut Context) { + let Some(hosting) = self.hosting.clone() else { + return; + }; + let on = self.settings.allow_remote_connections + || matches!(self.hosting_status, HostingStatus::Starting); + if on { + self.edit_setting(|settings| settings.allow_remote_connections = false, cx); + hosting.stop(); + self.hosting_status = HostingStatus::Off; + cx.notify(); + return; + } + self.hosting_status = HostingStatus::Starting; + cx.notify(); + self.call( + async move { Ok(hosting.start(crate::remote::DEFAULT_LISTEN).await) }, + cx, + |this, result, cx| { + let status = result.unwrap_or_else(HostingStatus::Failed); + if matches!(status, HostingStatus::Listening { .. }) { + this.edit_setting(|settings| settings.allow_remote_connections = true, cx); + } + this.hosting_status = status; + cx.notify(); + }, + ); + } + + /// Re-read the controller until a start in flight resolves, so a + /// screen opened during the launch-time start shows the outcome. + fn watch_hosting_start(&self, cx: &mut Context) { + let Some(hosting) = self.hosting.clone() else { + return; + }; + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_millis(500)) + .await; + let status = hosting.status(); + let settled = !matches!(status, HostingStatus::Starting); + let alive = this + .update(cx, |this, cx| { + if settled { + this.hosting_status = status; + cx.notify(); + } + }) + .is_ok(); + if settled || !alive { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + + fn load_paired_devices(&self, cx: &mut Context) { + if self.hosting.is_none() { + return; + } + let user_id = self.user_id.clone(); + self.call( + async move { crate::remote::host::list_devices(&user_id) }, + cx, + |this, result, cx| { + match result { + Ok(devices) => this.paired_devices = devices, + Err(message) => this.hosts_notice = Some(message), + } + cx.notify(); + }, + ); + } + + /// Publish a code for the running host. Only a listening host can + /// accept it, so the button is inert otherwise. + fn generate_pairing_code(&mut self, cx: &mut Context) { + if !matches!(self.hosting_status, HostingStatus::Listening { .. }) { + return; + } + let user_id = self.user_id.clone(); + self.call( + async move { crate::remote::host::publish_pairing_code(&user_id) }, + cx, + |this, result, cx| { + match result { + Ok((code, pending)) => { + this.pairing_code = Some((code.display(), pending.expires_ms)); + this.hosts_notice = None; + this.watch_pairing_code(cx); + } + Err(message) => this.hosts_notice = Some(message), + } + cx.notify(); + }, + ); + } + + /// Keep the shown code honest: it goes when the host consumed it (a + /// device paired, so the list is re-read) or it expired. + fn watch_pairing_code(&self, cx: &mut Context) { + let backend = self.backend.clone(); let user_id = self.user_id.clone(); - let task = spawn_backend.spawn(async move { - let scope = usage_backend.account_scope(&user_id); - scope.map(|scope| settings::load_usage(&scope)) + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(2)) + .await; + let user_id = user_id.clone(); + let pending = backend + .spawn(async move { crate::remote::host::pending_pairing_code(&user_id) }) + .await; + let valid = matches!(pending, Ok(Ok(Some(_)))); + let keep = this.update(cx, |this, cx| { + if this.pairing_code.is_none() { + return false; + } + if valid { + return true; + } + this.pairing_code = None; + this.load_paired_devices(cx); + cx.notify(); + false + }); + if !matches!(keep, Ok(true)) { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + + fn revoke_device(&mut self, key: &str, cx: &mut Context) { + let user_id = self.user_id.clone(); + let key = key.to_string(); + self.call( + async move { crate::remote::host::revoke_device(&user_id, &key) }, + cx, + |this, result, cx| { + if let Err(message) = result { + this.hosts_notice = Some(message); + } + this.load_paired_devices(cx); + }, + ); + } + + /// This machine's host role: the toggle, where it listens, the pairing + /// code, and the paired devices. + fn render_remote_access(&self, cx: &mut Context) -> Div { + let mut pane = div() + .flex() + .flex_col() + .gap_4() + .child(section_title("Remote access")) + .child(toggle_row( + "Allow remote connections", + "Serve this machine's tasks to paired devices on the LAN or a Tailscale \ + network. Pairing is the only gate; traffic is end-to-end encrypted.", + self.settings.allow_remote_connections + || matches!(self.hosting_status, HostingStatus::Starting), + cx.listener(|this, _event, _window, cx| { + this.toggle_remote_connections(cx); + }), + )); + let listening = matches!(self.hosting_status, HostingStatus::Listening { .. }); + let status = match &self.hosting_status { + HostingStatus::Off => "Not listening.".to_string(), + HostingStatus::Starting => "Starting\u{2026}".to_string(), + HostingStatus::Listening { + listen, + host_id, + name, + } => { + let short: String = host_id.chars().take(10).collect(); + match listen.parse::() { + // Every interface: no single address to show. + Ok(address) if address.ip().is_unspecified() => format!( + "Listening on port {} as \"{name}\" (key {short}\u{2026}). Devices \ + reach it at this machine's LAN or Tailscale address and that port.", + address.port() + ), + _ => format!("Listening on {listen} as \"{name}\" (key {short}\u{2026})."), + } + } + HostingStatus::Failed(error) => format!("Not listening: {error}"), + }; + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(status), + ); + if self.settings.allow_remote_connections + || matches!(self.hosting_status, HostingStatus::Starting) + { + pane = pane.child( + div() + .flex() + .items_center() + .gap_3() + .child( + widgets::secondary_button("generate-pairing-code") + .when(!listening, |button| button.opacity(0.5)) + .when(listening, |button| { + button.on_click(cx.listener(|this, _event, _window, cx| { + this.generate_pairing_code(cx); + })) + }) + .child("Generate pairing code"), + ) + .when_some(self.pairing_code.as_ref(), |row, (code, _)| { + // The code is read across the room or copied into + // another machine: large, monospaced, and selectable + // by a copy button rather than a drag. + let code: gpui::SharedString = code.clone().into(); + row.child( + div() + .px_3() + .py_1p5() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_input())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .font_family(crate::assets::FONT_MONO) + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_primary())) + .child(code.clone()), + ) + .child(widgets::copy_button( + "copy-pairing-code", + code, + None, + Some(cx.entity_id()), + )) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("valid for 5 minutes, one device"), + ) + }), + ); + } + pane = pane.child(section_title("Paired devices")); + if self.paired_devices.is_empty() { + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("No devices have paired with this machine."), + ); + } + for device in &self.paired_devices { + let key = device.public_key.clone(); + let short: String = device.public_key.chars().take(10).collect(); + pane = pane.child( + widgets::card_row() + .id(gpui::SharedString::from(format!( + "device-{}", + device.public_key + ))) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child(device.name.clone()), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(format!( + "key {short}\u{2026}{}", + device + .user_id + .as_deref() + .map(|user| format!(" \u{b7} account {user}")) + .unwrap_or_default() + )), + ), + ) + .child( + widgets::ghost_button(gpui::SharedString::from(format!( + "revoke-device-{}", + device.public_key + ))) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.revoke_device(&key, cx); + })) + .child("Revoke"), + ), + ); + } + pane + } + + fn render_hosts_pane(&self, cx: &mut Context) -> Div { + let mut pane = div() + .flex() + .flex_col() + .gap_4() + .child(section_title("Hosts")) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child( + "A host is another machine running `maple-agent serve`. Its tasks join \ + the sidebar and run there. Pair once with the code the host prints; \ + later connections need no code.", + ), + ); + if self.saved_hosts.is_empty() { + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("No hosts yet."), + ); + } + for row in &self.host_rows { + let online = row.online; + let id = row.id.clone(); + let mut column = div() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child(row.name.clone()), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(if online { + theme::accent() + } else { + theme::text_muted() + })) + .child(if online { "online" } else { "offline" }), + ) + .when_some(row.version.clone(), |line, version| { + line.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(version), + ) + }), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .line_clamp(1) + .text_ellipsis() + .child(row.detail.clone()), + ); + for note in &row.notes { + column = column.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_primary())) + .child(note.clone()), + ); + } + pane = pane.child( + widgets::card_row() + .id(gpui::SharedString::from(format!("host-{}", row.id))) + .flex() + .items_center() + .justify_between() + .gap_4() + .child(column) + .child( + widgets::ghost_button(gpui::SharedString::from(format!( + "remove-host-{}", + row.id + ))) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.remove_host(&id, cx); + })) + .child("Remove"), + ), + ); + } + pane = pane + .child(section_title("Add a host")) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child( + "On the host, run `maple-agent serve pair` and enter its address and \ + the code here within five minutes.", + ), + ) + .child(labeled_input("Address", self.host_address.clone())) + .child(labeled_input("Pairing code", self.host_code.clone())) + .child(labeled_input("Name", self.host_name.clone())) + .child( + div().flex().items_center().gap_3().child( + widgets::primary_button("pair-host") + .on_click(cx.listener(|this, _event, _window, cx| { + this.pair_host(cx); + })) + .child(if self.pairing { "Pairing…" } else { "Pair" }), + ), + ); + if let Some(notice) = &self.hosts_notice { + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(notice.clone()), + ); + } + pane.child(self.render_remote_access(cx)) + } + + /// The host selector row, shown on host-scoped sections when more than + /// one host is connected. + fn render_host_selector( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + if self.hosts.len() <= 1 { + return None; + } + Some(self.setting_menu_row( + "Host", + "Which host these settings belong to.", + SettingMenu::Host, + window, + cx, + )) + } + + fn load_usage(&self, cx: &mut Context) { + let host = self.host.clone(); + self.call( + async move { host.usage_summary().await }, + cx, + |this, result, cx| { + this.usage = Some(result.unwrap_or_else(|error| { + log::debug!("usage summary unavailable: {error}"); + UsageSummary::default() + })); + cx.notify(); + }, + ); + } + + /// Read the host's defaults for new tasks and show them. + fn load_session_defaults(&self, cx: &mut Context) { + let host = self.host.clone(); + self.call( + async move { host.session_defaults().await }, + cx, + |this, result, cx| { + match result { + Ok(defaults) => this.apply_session_defaults(defaults, cx), + Err(message) => this.prompt_notice = Some(message), + } + cx.notify(); + }, + ); + } + + fn apply_session_defaults(&mut self, defaults: HostSessionDefaults, cx: &mut Context) { + let prompt_text = defaults.effective_harness_instructions(); + self.prompt_editor.update(cx, |input, cx| { + if input.text() != prompt_text { + input.set_text(&prompt_text, cx); + } }); - cx.spawn(async move |this, cx| { - let usage = task.await.ok().flatten().unwrap_or_default(); - this.update(cx, |this, cx| { - this.usage = Some(usage); + self.defaults = defaults; + } + + /// The host's permission default as the UI's mode. + fn permission_default(&self) -> PermissionMode { + PermissionMode::parse(&self.defaults.permission_mode) + } + + /// Change the host's session defaults: apply to the local copy, hand + /// the whole record to the host, and re-render. A rejected save puts + /// the host's answer back. + fn edit_session_defaults( + &mut self, + update: impl FnOnce(&mut HostSessionDefaults), + cx: &mut Context, + ) { + update(&mut self.defaults); + let host = self.host.clone(); + let defaults = self.defaults.clone(); + self.call( + async move { host.set_session_defaults(defaults).await }, + cx, + |this, result, cx| { + if let Err(message) = result { + this.prompt_notice = Some(message); + this.load_session_defaults(cx); + } cx.notify(); - }) - .ok(); - }) - .detach(); + }, + ); + cx.notify(); } /// Change one setting: apply it to the local copy, queue the write @@ -768,11 +1596,19 @@ impl SettingsScreen { /// share this order, so an index means the same option in both. fn menu_options(&self, menu: SettingMenu) -> Vec { match menu { + SettingMenu::Host => self + .hosts + .iter() + .map(|host| SettingOption { + label: host.name.clone(), + current: host.id == *self.host.id(), + }) + .collect(), SettingMenu::Permission => [PermissionMode::SmartApprove, PermissionMode::Auto] .iter() .map(|&mode| SettingOption { label: mode.label().to_string(), - current: self.settings.default_permission_mode == mode, + current: self.permission_default() == mode, }) .collect(), SettingMenu::Appearance => [ @@ -824,7 +1660,8 @@ impl SettingsScreen { /// The saved value shown on the dropdown's trigger button. fn menu_value(&self, menu: SettingMenu) -> String { match menu { - SettingMenu::Permission => self.settings.default_permission_mode.label().to_string(), + SettingMenu::Host => self.current_host_name(), + SettingMenu::Permission => self.permission_default().label().to_string(), SettingMenu::Appearance => self.theme.label().to_string(), SettingMenu::ChatFont => { crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family) @@ -944,13 +1781,18 @@ impl SettingsScreen { cx: &mut Context, ) { match menu { + SettingMenu::Host => { + if let Some(host) = self.hosts.get(index).cloned() { + self.select_host(host, cx); + } + } SettingMenu::Permission => { let Some(mode) = [PermissionMode::SmartApprove, PermissionMode::Auto].get(index) else { return; }; - let mode = *mode; - self.edit_setting(move |settings| settings.default_permission_mode = mode, cx); + let mode = mode.as_str().to_string(); + self.edit_session_defaults(move |defaults| defaults.permission_mode = mode, cx); } SettingMenu::Appearance => { let Some(preference) = [ @@ -995,8 +1837,8 @@ impl SettingsScreen { } fn toggle_web_default(&mut self, cx: &mut Context) { - let next = !self.settings.default_web_enabled; - self.edit_setting(move |settings| settings.default_web_enabled = next, cx); + let next = !self.defaults.web_enabled; + self.edit_session_defaults(move |defaults| defaults.web_enabled = next, cx); } fn choose_theme(&mut self, preference: theme::Preference, cx: &mut Context) { @@ -1140,20 +1982,20 @@ impl SettingsScreen { self.set_application_vim_enabled(next, cx); } - /// Persist the editor text as the harness instructions and hand it to - /// the running backend. Text equal to the default is saved as empty so - /// a future default change still applies. + /// Persist the editor text as the host's harness instructions. Text + /// equal to the default is saved as empty so a future default change + /// still applies. fn save_prompt(&mut self, cx: &mut Context) { let text = self.prompt_editor.read(cx).text().trim().to_string(); - self.settings.harness_instructions = if text == settings::DEFAULT_HARNESS_INSTRUCTIONS { + let instructions = if text == settings::DEFAULT_HARNESS_INSTRUCTIONS { String::new() } else { text }; - let instructions = self.settings.harness_instructions.clone(); - settings::update_settings_in_background(move |s| s.harness_instructions = instructions); - self.backend - .set_harness_instructions(self.settings.effective_harness_instructions()); + self.edit_session_defaults( + move |defaults| defaults.harness_instructions = instructions, + cx, + ); self.prompt_notice = Some("Saved. New tasks use this prompt.".to_string()); cx.notify(); } @@ -1327,7 +2169,14 @@ impl SettingsScreen { } // A dropdown belongs to the pane that opened it. self.close_setting_menu(cx); + let entering_hosts = section == Section::Hosts && self.section != Section::Hosts; self.section = section; + if entering_hosts { + // The list is a snapshot from open; hosts may have connected + // or recorded a version since. + self.reload_saved_hosts(); + self.watch_hosts(cx); + } if self.settings.application_vim_enabled { self.application_vim.section = section; self.reconcile_application_vim_target(); @@ -1341,6 +2190,11 @@ impl SettingsScreen { } } +/// The newest Agent release the update check found, for the Hosts rows. +fn latest_release() -> Option<&'static str> { + crate::update::available().map(|update| update.version.as_str()) +} + fn merge_shortcut_overrides(settings: &mut AppSettings, shortcut_overrides: ShortcutOverrides) { settings.shortcut_overrides = shortcut_overrides; } @@ -1532,12 +2386,20 @@ impl SettingsScreen { .p_6() .track_scroll(&self.pane_scroll) .overflow_y_scroll(); + if self.section.is_host_scoped() + && let Some(selector) = self.render_host_selector(window, cx) + { + pane = pane.child(selector); + } match self.section { + Section::Hosts => { + pane = pane.child(self.render_hosts_pane(cx)); + } Section::General => { pane = pane .child(section_title("Defaults")) .child({ - let mode = self.settings.default_permission_mode; + let mode = self.permission_default(); self.application_target( || SettingsTarget::General(GeneralTarget::Permission), self.setting_menu_row( @@ -1555,7 +2417,7 @@ impl SettingsScreen { "New tasks can use the web", "Offers web_search and open_url to the model. Each task can \ switch web access on or off from its composer.", - self.settings.default_web_enabled, + self.defaults.web_enabled, cx.listener(|this, _event, _window, cx| { this.toggle_web_default(cx); }), @@ -3163,6 +4025,23 @@ fn toggle_row( )) } +/// A label above a text input, for short forms. The input frame carries +/// the themed text and background colors; a bare input inherits none. +fn labeled_input(label: &str, input: Entity) -> Div { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_secondary())) + .child(label.to_string()), + ) + .child(widgets::input_frame().text_sm().child(input)) +} + fn info_row(label: &str, value: String) -> Div { widgets::card_row() .flex() @@ -3203,7 +4082,7 @@ fn stat(label: &str, value: String) -> Div { ) } -fn usage_table(title: &str, rows: &[crate::settings::UsageRow]) -> Div { +fn usage_table(title: &str, rows: &[maple_agent::host::UsageRow]) -> Div { let mut table = div().flex().flex_col().gap_2().child( div() .text_sm() @@ -3341,8 +4220,8 @@ mod tests { #[test] fn shortcut_result_preserves_unrelated_general_setting() { let mut settings = AppSettings::default(); - settings.default_web_enabled = !settings.default_web_enabled; - let expected_web_enabled = settings.default_web_enabled; + settings.tool_details = !settings.tool_details; + let expected_tool_details = settings.tool_details; settings .shortcut_overrides .insert("chat.focus_search".into(), None); @@ -3353,7 +4232,7 @@ mod tests { merge_shortcut_overrides(&mut settings, overrides.clone()); - assert_eq!(settings.default_web_enabled, expected_web_enabled); + assert_eq!(settings.tool_details, expected_tool_details); assert_eq!(settings.shortcut_overrides, overrides); } diff --git a/apps/maple-agent/app/src/ui/settings/hosts.rs b/apps/maple-agent/app/src/ui/settings/hosts.rs new file mode 100644 index 000000000..c643f778a --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/hosts.rs @@ -0,0 +1,269 @@ +//! The rows of the Hosts pane: each saved host with its connection state +//! and the version it announced, compared with this app's own. Rows are +//! computed when the list or a host's state changes, never in render. + +use gpui::SharedString; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::HostVersion; +use semver::Version; + +/// How a host's build relates to this app's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Relation { + /// The same version and, where both are known, the same build. + Same, + /// The same version built from another revision. + DifferentBuild, + /// The host's version is lower. + Behind, + /// The host's version is higher. + Newer, + /// One of the versions is not semver; nothing can be said. + Unknown, +} + +pub fn relation(host: &HostVersion, app: &HostVersion) -> Relation { + let (Ok(host_version), Ok(app_version)) = + (Version::parse(&host.version), Version::parse(&app.version)) + else { + return Relation::Unknown; + }; + match host_version.cmp(&app_version) { + std::cmp::Ordering::Less => Relation::Behind, + std::cmp::Ordering::Greater => Relation::Newer, + std::cmp::Ordering::Equal => match (&host.build, &app.build) { + (Some(host_build), Some(app_build)) if host_build != app_build => { + Relation::DifferentBuild + } + _ => Relation::Same, + }, + } +} + +/// The lines shown under a host's version: how it relates to this app, +/// and the newest release the update check found when that is newer than +/// the host. `latest` is that release's version, if any. +pub fn notes(host: &HostVersion, app: &HostVersion, latest: Option<&str>) -> Vec { + let mut notes = Vec::new(); + match relation(host, app) { + Relation::Behind => notes.push("Behind this app; update the host".to_string()), + Relation::Newer => notes.push("Newer than this app; update this app".to_string()), + Relation::DifferentBuild => notes.push("Different build from this app".to_string()), + Relation::Same | Relation::Unknown => {} + } + if let Some(latest) = latest + && let (Ok(latest_version), Ok(host_version)) = + (Version::parse(latest), Version::parse(&host.version)) + && latest_version > host_version + { + notes.push(format!("Update available: {latest}")); + } + notes +} + +/// One saved host as the pane shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRow { + pub id: String, + pub name: SharedString, + pub online: bool, + /// `0.1.0 (63bcff5c)` while online, `last seen 0.1.0 (63bcff5c)` + /// while offline; absent when the host never announced a version. + pub version: Option, + pub notes: Vec, + /// The addresses and the start of the key. + pub detail: SharedString, +} + +/// The rows for `saved`. `live` answers what a host's current connection +/// announced, or `None` while it is offline; an offline host shows what it +/// announced last. +pub fn rows( + saved: &[SavedHost], + app: &HostVersion, + latest: Option<&str>, + live: impl Fn(&str) -> Option, +) -> Vec { + saved + .iter() + .map(|host| { + let connected = live(&host.id); + let online = connected.is_some(); + let announced = connected.or_else(|| { + host.last_seen_version.clone().map(|version| HostVersion { + version, + build: host.last_seen_build.clone(), + }) + }); + let version = announced.as_ref().map(|announced| { + if online { + announced.label() + } else { + format!("last seen {}", announced.label()) + } + }); + let notes = announced + .as_ref() + .map(|announced| notes(announced, app, latest)) + .unwrap_or_default(); + let short_id: String = host.id.chars().take(10).collect(); + let connections = host + .connections + .iter() + .map(|connection| connection.label().to_string()) + .collect::>() + .join(", "); + HostRow { + id: host.id.clone(), + name: host.name.clone().into(), + online, + version: version.map(Into::into), + notes: notes.into_iter().map(Into::into).collect(), + detail: format!("{connections} \u{b7} key {short_id}\u{2026}").into(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use maple_remote::hosts::HostConnection; + + fn version(version: &str, build: Option<&str>) -> HostVersion { + HostVersion { + version: version.to_string(), + build: build.map(str::to_string), + } + } + + #[test] + fn a_host_is_compared_by_version_then_by_build() { + let app = version("0.2.0", Some("63bcff5c")); + assert_eq!( + relation(&version("0.1.9", Some("63bcff5c")), &app), + Relation::Behind + ); + assert_eq!( + relation(&version("0.2.0", Some("63bcff5c")), &app), + Relation::Same + ); + assert_eq!( + relation(&version("0.2.0", Some("abc1234")), &app), + Relation::DifferentBuild + ); + assert_eq!( + relation(&version("0.2.1", Some("abc1234")), &app), + Relation::Newer + ); + // A side without a build cannot be told apart by it. + assert_eq!(relation(&version("0.2.0", None), &app), Relation::Same); + assert_eq!( + relation(&version("0.2.0", Some("abc1234")), &version("0.2.0", None)), + Relation::Same + ); + assert_eq!(relation(&version("v0.2.0", None), &app), Relation::Unknown); + } + + #[test] + fn notes_name_the_relation_and_an_available_update() { + let app = version("0.2.0", Some("63bcff5c")); + assert_eq!( + notes(&version("0.1.9", Some("63bcff5c")), &app, None), + vec!["Behind this app; update the host"] + ); + assert!(notes(&version("0.2.0", Some("63bcff5c")), &app, None).is_empty()); + assert_eq!( + notes(&version("0.2.0", Some("abc1234")), &app, None), + vec!["Different build from this app"] + ); + assert_eq!( + notes(&version("0.3.0", None), &app, None), + vec!["Newer than this app; update this app"] + ); + // The update note joins the relation and stands alone when the + // host matches this app but a newer release exists. + assert_eq!( + notes(&version("0.1.9", None), &app, Some("0.3.0")), + vec![ + "Behind this app; update the host", + "Update available: 0.3.0" + ] + ); + assert_eq!( + notes(&version("0.2.0", Some("63bcff5c")), &app, Some("0.3.0")), + vec!["Update available: 0.3.0"] + ); + // A release the host already runs, or older, is not offered. + assert!( + notes( + &version("0.3.0", None), + &version("0.3.0", None), + Some("0.3.0") + ) + .is_empty() + ); + assert!( + notes( + &version("0.3.0", None), + &version("0.3.0", None), + Some("0.2.0") + ) + .is_empty() + ); + assert!(notes(&version("nope", None), &app, Some("also nope")).is_empty()); + } + + #[test] + fn rows_show_the_live_version_online_and_the_last_seen_one_offline() { + let saved = |id: &str, last_seen: Option<(&str, Option<&str>)>| SavedHost { + id: id.to_string(), + name: format!("host {id}"), + connections: vec![HostConnection::Direct { + address: "100.64.0.7:7130".to_string(), + }], + paired_at_ms: 0, + last_seen_version: last_seen.map(|(version, _)| version.to_string()), + last_seen_build: last_seen.and_then(|(_, build)| build.map(str::to_string)), + }; + let app = version("0.2.0", Some("63bcff5c")); + let hosts = vec![ + saved("online-host", Some(("0.1.0", None))), + saved("offline-host", Some(("0.1.5", Some("abc1234")))), + saved("never-seen", None), + ]; + let rows = rows(&hosts, &app, Some("0.2.0"), |id| { + (id == "online-host").then(|| version("0.2.0", Some("abc1234"))) + }); + assert_eq!(rows.len(), 3); + + // Online: the connection's hello wins over what was saved. + assert!(rows[0].online); + assert_eq!(rows[0].version.as_deref(), Some("0.2.0 (abc1234)")); + assert_eq!(rows[0].notes, vec!["Different build from this app"]); + assert_eq!(rows[0].name.as_ref(), "host online-host"); + assert_eq!( + rows[0].detail.as_ref(), + "100.64.0.7:7130 \u{b7} key online-hos\u{2026}" + ); + + // Offline: the last seen version, still compared. + assert!(!rows[1].online); + assert_eq!( + rows[1].version.as_deref(), + Some("last seen 0.1.5 (abc1234)") + ); + assert_eq!( + rows[1].notes, + vec![ + "Behind this app; update the host", + "Update available: 0.2.0" + ] + ); + + // Never connected on a build that records versions: nothing to say. + assert!(!rows[2].online); + assert_eq!(rows[2].version, None); + assert!(rows[2].notes.is_empty()); + } +} diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 54aeb8af1..0c22b06c9 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -182,7 +182,7 @@ impl SettingsScreen { .map(|server| SettingsTarget::McpServer(server.name.clone())), ) .collect(), - Section::Usage | Section::About => Vec::new(), + Section::Hosts | Section::Usage | Section::About => Vec::new(), } } @@ -662,12 +662,15 @@ mod tests { fn integration_targets_follow_the_visible_control_order(cx: &mut TestAppContext) { cx.executor().allow_parking(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), + Vec::new(), + None, + None, "user".to_string(), crate::settings::AppSettings::default(), crate::shortcuts::ShortcutSnapshot { @@ -748,12 +751,15 @@ mod tests { fn application_vim_off_keeps_settings_projection_empty(cx: &mut TestAppContext) { cx.executor().allow_parking(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), + Vec::new(), + None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: false, @@ -822,12 +828,15 @@ mod tests { } let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), + Vec::new(), + None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -887,12 +896,15 @@ mod tests { } let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), + Vec::new(), + None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -969,12 +981,15 @@ mod tests { fn dropdown_screen(cx: &mut TestAppContext, application_vim: bool) -> Entity { cx.executor().allow_parking(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), + Vec::new(), + None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: application_vim, diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 082cf21a4..9a443024c 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -86,6 +86,9 @@ type KeyHandler = Box< dyn Fn(&gpui::KeyDownEvent, &SharedString, &mut Window, &mut Context) -> bool + 'static, >; +/// Called for the up (-1) and down (1) arrows; returning true consumes +/// the arrow instead of moving the caret. +type VerticalHandler = Box) -> bool + 'static>; #[derive(Clone)] struct ImeBaseline { @@ -146,6 +149,9 @@ pub struct TextInput { on_paste_image: Option, /// First look at every key press; returning true consumes the key. on_key: Option, + /// The up and down arrows are actions of this input, so the key hook + /// never sees them; a list above the input takes them here. + on_vertical: Option, /// Underline words the dictionary rejects (composer only). spell_check: bool, /// Byte ranges of misspelled words, refreshed on every content change @@ -246,6 +252,17 @@ impl TextInput { self } + /// Take the up and down arrows before they move the caret, for an + /// input that drives a list. The handler runs inside this entity's + /// update: defer any write back to the input. + pub fn on_vertical( + mut self, + handler: impl Fn(isize, &mut Window, &mut Context) -> bool + 'static, + ) -> Self { + self.on_vertical = Some(Box::new(handler)); + self + } + pub fn new(placeholder: &str, cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle(), @@ -273,6 +290,7 @@ impl TextInput { on_enter: None, on_paste_image: None, on_key: None, + on_vertical: None, spell_check: false, misspelled: Vec::new(), spell_generation: 0, @@ -1382,11 +1400,24 @@ impl TextInput { } } + /// Offer an arrow to the vertical hook; true when it took it. + fn take_vertical(&mut self, delta: isize, window: &mut Window, cx: &mut Context) -> bool { + let Some(on_vertical) = self.on_vertical.take() else { + return false; + }; + let consumed = on_vertical(delta, window, cx); + self.on_vertical = Some(on_vertical); + consumed + } + fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { if matches!(self.vim_mode(), Some(VimMode::Normal | VimMode::Visual)) { self.execute_vim_command(VimCommand::Motion(Motion::Up), cx); return; } + if self.take_vertical(-1, window, cx) { + return; + } self.ensure_navigation_layout(window); match self.vertical_neighbor(-1, None) { Some((offset, affinity)) => { @@ -1406,6 +1437,9 @@ impl TextInput { self.execute_vim_command(VimCommand::Motion(Motion::Down), cx); return; } + if self.take_vertical(1, window, cx) { + return; + } self.ensure_navigation_layout(window); match self.vertical_neighbor(1, None) { Some((offset, affinity)) => { diff --git a/apps/maple-agent/crates/maple-agent/Cargo.toml b/apps/maple-agent/crates/maple-agent/Cargo.toml index 812dc339b..680265177 100644 --- a/apps/maple-agent/crates/maple-agent/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent/Cargo.toml @@ -38,6 +38,13 @@ sha2 = "0.10" agent-client-protocol = { version = "=2.0.0", optional = true, default-features = false, features = ["unstable_elicitation", "unstable_end_turn_token_usage"] } base64 = "0.22" bytes = "1" +# Host-side readers of the account stores: the Goose usage ledger (read-only) +# and the app-owned tool summary store. +rusqlite = { version = "0.32", features = ["bundled"] } +# Watches a project root's git dir so the branch shown to clients follows a +# checkout made by the agent or from a terminal. +notify = "8" +dirs = "6" [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = ["Win32_System_Threading"] } diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index e97efb6cb..83e7039f3 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -103,7 +103,13 @@ use web_tools::WebToolState; const DEFAULT_AGENT_MODEL: &str = "glm-5-3"; const LEGACY_AGENT_DEFAULT_MODEL: &str = "auto:powerful"; const PREVIOUS_RECOMMENDED_AGENT_MODEL: &str = "glm-5-2"; -const DEFAULT_GOOSE_MODE: &str = "smart_approve"; +/// Permission policy name for "confirm each gated tool call". +pub const PERMISSION_MODE_SMART_APPROVE: &str = "smart_approve"; +/// Permission policy name for "ask before every tool call". +pub const PERMISSION_MODE_APPROVE: &str = "approve"; +/// Permission policy name for "approve every tool call". +pub const PERMISSION_MODE_AUTO: &str = "auto"; +const DEFAULT_GOOSE_MODE: &str = PERMISSION_MODE_SMART_APPROVE; // Keep Goose on its ActionRequired path so Maple can apply the currently selected // policy at every tool boundary, including when the user changes it mid-run. const GOOSE_PERMISSION_ROUTING_MODE: GooseMode = GooseMode::SmartApprove; @@ -956,6 +962,11 @@ impl MapleAgentService { /// Desktop commands create a fresh handle at their boundary. Long-lived /// adapters such as ACP retain a handle, which makes account clearing an /// explicit revocation point instead of silently rebinding the adapter. + /// Where this service keeps its configuration and account data. + pub fn paths(&self) -> &AgentPathLayout { + &self.host.paths + } + pub async fn handle_for_user(&self, user_id: &str) -> Result { let account_scope = account_scope(user_id)?; let generation = account_generation(self, &account_scope).await; @@ -9223,9 +9234,9 @@ fn is_caller_mediated_mode(mode: GooseMode) -> bool { fn parse_user_permission_mode(mode: &str) -> Result { match mode { - "auto" => Ok(GooseMode::Auto), - "approve" => Ok(GooseMode::Approve), - "smart_approve" => Ok(GooseMode::SmartApprove), + PERMISSION_MODE_AUTO => Ok(GooseMode::Auto), + PERMISSION_MODE_APPROVE => Ok(GooseMode::Approve), + PERMISSION_MODE_SMART_APPROVE => Ok(GooseMode::SmartApprove), _ => Err(format!("Unsupported Agent permission mode: {mode}")), } } @@ -9268,7 +9279,8 @@ fn is_removed_project_root(path: &str, removed_project_roots: &[String]) -> bool } fn normalize_project_root(path: &Path) -> Result { - let canonical = path + let expanded = expand_home(path); + let canonical = expanded .canonicalize() .map_err(|e| format!("{}: {e}", path.display()))?; if !canonical.is_dir() { @@ -9277,6 +9289,42 @@ fn normalize_project_root(path: &Path) -> Result { Ok(canonical) } +/// `~` and `~/...` mean this host's home directory: a typed path arrives +/// as written on the client, which cannot know the host's home. +fn expand_home(path: &Path) -> PathBuf { + let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else { + return path.to_path_buf(); + }; + if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with(std::path::MAIN_SEPARATOR)) { + return path.to_path_buf(); + } + match dirs::home_dir() { + Some(home) => PathBuf::from(format!("{}{rest}", home.display())), + None => path.to_path_buf(), + } +} + +#[cfg(test)] +mod home_expansion_tests { + use super::*; + + #[test] + fn tilde_means_the_home_directory() { + let Some(home) = dirs::home_dir() else { + return; + }; + assert_eq!(expand_home(Path::new("~")), home); + assert_eq!(expand_home(Path::new("~/work")), home.join("work")); + // A name that merely starts with a tilde is left alone. + assert_eq!( + expand_home(Path::new("~ben/work")), + PathBuf::from("~ben/work") + ); + assert_eq!(expand_home(Path::new("/tmp/~")), PathBuf::from("/tmp/~")); + assert!(normalize_project_root(Path::new("~")).is_ok()); + } +} + fn agent_root_dir(paths: &AgentPathLayout) -> Result { let path = paths.config_root.clone(); fs::create_dir_all(&path)?; @@ -14401,6 +14449,7 @@ mod tests { trusted: true, }], removed_project_roots: Vec::new(), + ..AgentConfig::default() }; apply_project_root_removal(&mut config, &removed, Some(&fallback)).unwrap(); @@ -15541,6 +15590,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(migrate_agent_config(&mut config)); @@ -15556,6 +15606,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(migrate_agent_config(&mut config)); @@ -15571,6 +15622,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(migrate_agent_config(&mut config)); @@ -15587,6 +15639,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(!migrate_agent_config(&mut config)); @@ -15608,6 +15661,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: vec![removed.clone()], + ..AgentConfig::default() }; let resolved = resolve_project_root(None, &config).unwrap(); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs b/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs index 975f2a1ec..e6aad7e72 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; pub(super) const MAX_AGENT_IMAGE_BYTES: usize = 10 * 1024 * 1024; pub(super) const MAX_AGENT_IMAGES_PER_MESSAGE: usize = 10; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgentImageUpload { pub name: String, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 2e9df8594..d52c0168e 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -25,6 +25,17 @@ pub struct AgentConfig { pub project_trust: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub removed_project_roots: Vec, + /// Permission policy for new tasks: `smart_approve` or `auto`. `None` + /// means the host never saved one and the default applies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_permission_mode: Option, + /// Whether new tasks can use the web tools; `None` means the default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_web_enabled: Option, + /// Opening system prompt text for tasks this host runs. `None` or blank + /// means the built-in default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_instructions: Option, } pub(super) fn default_agent_model() -> String { @@ -54,6 +65,9 @@ impl Default for AgentConfig { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + default_permission_mode: None, + default_web_enabled: None, + harness_instructions: None, } } } @@ -65,13 +79,13 @@ pub struct AgentProjectTrust { pub trusted: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentProjectTrustFeature { Skills, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProjectTrustStatus { pub path: String, @@ -122,7 +136,7 @@ pub struct AgentMcpServer { /// Integration discovery is intentionally separate from MCP configuration: /// an integration may be installed without being enabled, and device-local /// launch details must not leak into the account's roaming configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIntegration { pub id: String, @@ -163,7 +177,7 @@ pub enum AgentIntegrationBackend { /// can be read before use, while portal-based desktops grant capability per /// session at first use and therefore require none up front. Callers must not /// re-derive that per-platform knowledge; ask [`AgentIntegrationPermissions`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentIntegrationPermissionKind { Accessibility, @@ -201,7 +215,7 @@ impl AgentIntegrationPermissionKind { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIntegrationPermission { pub kind: AgentIntegrationPermissionKind, @@ -214,7 +228,7 @@ pub struct AgentIntegrationPermission { /// An empty requirement list means the platform needs no pre-flight grant, so /// [`AgentIntegrationPermissions::ready`] is true. That is the single place /// where "may this integration run" is decided. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIntegrationPermissions { pub required: Vec, @@ -248,7 +262,7 @@ impl AgentIntegrationPermissions { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentIntegrationAvailability { NotDetected, @@ -256,14 +270,14 @@ pub enum AgentIntegrationAvailability { Available, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetIntegrationEnabledRequest { pub id: String, pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetupIntegrationRequest { pub id: String, @@ -296,7 +310,7 @@ pub(super) fn default_mcp_timeout_seconds() -> u64 { } /// A skill-derived slash command the composer can offer. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSlashCommand { pub name: String, @@ -305,7 +319,7 @@ pub struct AgentSlashCommand { } /// One answer choice, mirroring codex's request_user_input option. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentQuestionOption { pub label: String, @@ -315,7 +329,7 @@ pub struct AgentQuestionOption { /// One question in a request_user_input call: one to three related /// questions ride a single call and are answered together. The client adds /// a free-form "Other" answer next to these options. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentQuestion { pub multi_select: bool, @@ -325,7 +339,7 @@ pub struct AgentQuestion { pub options: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentMcpConnectionError { pub name: String, @@ -336,7 +350,8 @@ pub(super) const TTS_MODEL: &str = "voxtral-tts"; pub(super) const TRANSCRIPTION_MODEL: &str = "whisper-large-v3"; /// Voice endpoints the signed-in account can use. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AudioCapabilities { pub transcription: bool, pub speech: bool, @@ -457,7 +472,7 @@ pub enum AgentSessionIntegrationKind { ExternalAgent, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionMcpServer { pub name: String, @@ -469,7 +484,7 @@ pub struct AgentSessionMcpServer { pub available: bool, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetSessionMcpServerRequest { pub session_id: String, @@ -487,7 +502,7 @@ pub struct AgentStartRequest { pub mode: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeStatus { pub running: bool, @@ -505,7 +520,7 @@ pub struct RecentProjectRoot { pub last_used_ms: u128, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProjectRootRegistration { pub project_root: String, @@ -513,7 +528,7 @@ pub struct AgentProjectRootRegistration { pub config: AgentConfig, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentCreateSessionRequest { pub project_root: Option, @@ -529,7 +544,7 @@ pub struct AgentCreateSessionRequest { pub system_prompt: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSendMessageRequest { pub session_id: String, @@ -548,14 +563,14 @@ pub struct AgentSendMessageRequest { pub attachments: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentRenameSessionRequest { pub session_id: String, pub title: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentPermissionResponse { pub session_id: String, @@ -563,7 +578,8 @@ pub struct AgentPermissionResponse { pub decision: String, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AgentPermissionRequest { pub request_id: String, pub tool_name: String, @@ -571,7 +587,8 @@ pub struct AgentPermissionRequest { pub prompt: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum AgentPermissionDecision { AllowOnce, DenyOnce, @@ -579,7 +596,9 @@ pub enum AgentPermissionDecision { } impl AgentPermissionDecision { - pub(super) fn status(self) -> &'static str { + /// The wire spelling of the decision, as `AgentPermissionResponse` + /// carries it. + pub fn as_str(self) -> &'static str { match self { Self::AllowOnce => "allow_once", Self::DenyOnce => "deny_once", @@ -587,6 +606,10 @@ impl AgentPermissionDecision { } } + pub(super) fn status(self) -> &'static str { + self.as_str() + } + pub(super) fn goose_permission(self) -> Permission { match self { Self::AllowOnce => Permission::AllowOnce, @@ -602,21 +625,21 @@ pub enum AgentPermissionRouting { CallingSurface, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentPermissionModeRequest { pub session_id: String, pub mode: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetSessionWebRequest { pub session_id: String, pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentQueuedMessage { pub queue_id: String, @@ -625,25 +648,26 @@ pub struct AgentQueuedMessage { pub text: String, pub attachments: Vec, pub created_ms: u128, - #[serde(skip)] + #[serde(skip, default = "Message::user")] pub(super) message: Message, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentDesktopQueueSnapshot { pub revision: u64, pub items: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentQueueControlRequest { pub session_id: String, pub queue_id: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum AgentRunTerminal { Completed, Cancelled, @@ -662,7 +686,8 @@ pub struct AgentRunHandle { pub queue: AgentDesktopQueueSnapshot, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AgentRunUsage { pub(crate) input_tokens: u64, pub(crate) output_tokens: u64, @@ -1034,7 +1059,8 @@ impl Drop for AgentToolContextLease { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum AgentRunEvent { SessionUpdated(AgentSessionSummary), Started, @@ -1075,7 +1101,8 @@ pub enum AgentRunEvent { }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum AgentServiceEvent { RuntimeStatus(AgentRuntimeStatus), /// The agent asked the user one or more related questions (ask_user @@ -1112,7 +1139,7 @@ pub enum AgentServiceEvent { /// One subagent that is still working for a task. A caller that opens /// the task after the run ended reads these to rebuild its live view. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSubagent { /// Request ID of the `delegate` call that started it. @@ -1131,7 +1158,7 @@ pub struct AgentSubagent { } /// Which external agent a subagent row stands for. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalAgentRef { pub provider: String, @@ -1140,13 +1167,15 @@ pub struct ExternalAgentRef { /// One finished exchange of a `/btw` thread, replayed on a follow-up so /// the model sees the earlier side questions and answers. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SideQuestionTurn { pub question: String, pub answer: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum SideQuestionEvent { Chunk(String), Finished, @@ -1179,7 +1208,7 @@ impl AgentTaskState { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { pub id: String, @@ -1198,7 +1227,7 @@ pub struct AgentSessionSummary { pub acp: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionDetail { pub session: AgentSessionSummary, @@ -1207,7 +1236,7 @@ pub struct AgentSessionDetail { pub queue: AgentDesktopQueueSnapshot, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentTimelineItem { pub id: String, diff --git a/apps/maple-agent/crates/maple-agent/src/host/local/directories.rs b/apps/maple-agent/crates/maple-agent/src/host/local/directories.rs new file mode 100644 index 000000000..3b31a8f92 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/local/directories.rs @@ -0,0 +1,130 @@ +//! Directory completion for a typed project root. +//! +//! The host owns the filesystem, so it answers "what directories match +//! what I typed so far". Clients show the answer as-is and never parse or +//! filter paths themselves. + +use std::path::{Path, PathBuf}; + +use crate::host::DirectorySuggestion; + +/// Most suggestions one answer carries. +pub const SUGGESTION_LIMIT: usize = 50; + +/// Directories that complete `query`, absolute, sorted by name. +/// +/// An empty query lists the home directory. A query ending in a +/// separator lists that directory. Otherwise the last component is a +/// prefix filter on its parent. Hidden directories appear only when the +/// prefix starts with a dot. A leading `~` means the home directory. +/// Blocking: call from a blocking thread. +pub fn suggest(query: &str, home: Option<&Path>) -> Vec { + let query = query.trim(); + let expanded = match query.strip_prefix('~') { + Some(rest) => match home { + Some(home) => format!("{}{}", home.display(), rest), + None => return Vec::new(), + }, + None if query.is_empty() => match home { + Some(home) => format!("{}{}", home.display(), std::path::MAIN_SEPARATOR), + None => return Vec::new(), + }, + None => query.to_string(), + }; + let (parent, prefix) = split_query(&expanded); + if !parent.is_absolute() { + return Vec::new(); + } + let Ok(entries) = std::fs::read_dir(&parent) else { + return Vec::new(); + }; + let show_hidden = prefix.starts_with('.'); + let mut matches: Vec = entries + .flatten() + .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + if !show_hidden && name.starts_with('.') { + return None; + } + if !name.to_lowercase().starts_with(&prefix.to_lowercase()) { + return None; + } + Some(DirectorySuggestion { + path: entry.path().to_string_lossy().into_owned(), + name, + }) + }) + .collect(); + matches.sort_by_key(|suggestion| suggestion.name.to_lowercase()); + matches.truncate(SUGGESTION_LIMIT); + matches +} + +/// The directory to list and the name prefix to match in it. +fn split_query(query: &str) -> (PathBuf, String) { + if query.ends_with(std::path::MAIN_SEPARATOR) || query.ends_with('/') { + return (PathBuf::from(query), String::new()); + } + let path = Path::new(query); + let prefix = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let parent = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(query)); + (parent, prefix) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "maple-dirs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + for name in ["projects", "Photos", ".hidden", "plain"] { + std::fs::create_dir_all(root.join(name)).unwrap(); + } + std::fs::write(root.join("pfile"), "x").unwrap(); + root + } + + #[test] + fn prefix_filters_case_insensitively_and_skips_files_and_hidden() { + let root = fixture(); + let query = format!("{}/p", root.display()); + let names: Vec = suggest(&query, None).into_iter().map(|s| s.name).collect(); + assert_eq!(names, vec!["Photos", "plain", "projects"]); + let hidden: Vec = suggest(&format!("{}/.h", root.display()), None) + .into_iter() + .map(|s| s.name) + .collect(); + assert_eq!(hidden, vec![".hidden"]); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn trailing_separator_lists_the_directory_and_tilde_means_home() { + let root = fixture(); + let all = suggest(&format!("{}/", root.display()), None); + assert_eq!(all.len(), 3, "hidden stays out without a dot prefix"); + assert!(all.iter().all(|s| Path::new(&s.path).is_absolute())); + let via_home = suggest("~/pr", Some(&root)); + assert_eq!(via_home.len(), 1); + assert_eq!(via_home[0].name, "projects"); + let empty = suggest("", Some(&root)); + assert_eq!(empty.len(), 3); + assert!(suggest("relative/path", None).is_empty()); + assert!(suggest("", None).is_empty()); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/local/git.rs b/apps/maple-agent/crates/maple-agent/src/host/local/git.rs new file mode 100644 index 000000000..c6fb6bf4f --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/local/git.rs @@ -0,0 +1,422 @@ +//! Git branch reporting for watched project roots. +//! +//! The host, not the client, owns the checkout, so it reads `HEAD` and +//! watches the git dir. Clients receive [`HostEvent::ProjectBranch`] when +//! a watch starts and whenever the branch may have changed. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use notify::Watcher as _; + +use crate::host::{HostEvent, HostEventHub}; + +/// The directory that holds `HEAD` for a checkout, or `None` when `root` +/// is not one. Supports worktrees, whose `.git` is a file that points at +/// the real git dir. +pub fn git_dir(root: &Path) -> Option { + let dot_git = root.join(".git"); + if dot_git.is_dir() { + return Some(dot_git); + } + let pointer = std::fs::read_to_string(&dot_git).ok()?; + let target = pointer.trim().strip_prefix("gitdir:")?.trim(); + let target = Path::new(target); + Some(if target.is_absolute() { + target.to_path_buf() + } else { + root.join(target) + }) +} + +/// Current git branch from a git dir, or the short commit id when HEAD +/// is detached. `None` when there is no readable `HEAD`. +pub fn git_branch(git_dir: &Path) -> Option { + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + let head = head.trim(); + match head.strip_prefix("ref: ") { + Some(reference) => Some( + reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .to_string(), + ), + // Detached: a hex id. Anything else is a corrupt HEAD. + None => head + .get(..7) + .filter(|id| id.bytes().all(|byte| byte.is_ascii_hexdigit())) + .map(str::to_string), + } +} + +/// The branch of `root`, resolving its git dir first. +pub fn branch_of(root: &Path) -> Option { + git_dir(root).as_deref().and_then(git_branch) +} + +/// True when a watcher event means the branch may have changed: a semantic +/// change to a `HEAD` path (write, create, remove, or the rename pair of an +/// atomic replacement), or a rescan the backend requires. Access-only events +/// (open, read, close) are dropped: the branch read they would trigger emits +/// those same events again under Linux inotify, looping the watcher at full +/// CPU while idle (#945). Real writes still arrive as `Modify` on every +/// backend, so no true change is lost; `Any`/`Other` stay forwarded for +/// backends that cannot classify. +pub fn head_change_event(event: ¬ify::Event) -> bool { + if event.need_rescan() { + return true; + } + if matches!(event.kind, notify::EventKind::Access(_)) { + return false; + } + event + .paths + .iter() + .any(|path| path.file_name().is_some_and(|name| name == "HEAD")) +} + +struct BranchWatch { + /// Clients watching this root. The watcher lives while any remain. + watchers: usize, + /// `None` when the root is not a checkout or the watch could not start. + /// A later `watch` of the same root tries again, so a folder that is + /// initialised as a checkout while watched gets its watcher the next + /// time a client asks for it; nothing polls for `.git` in between. + watcher: Option, +} + +/// One watcher per root, shared by every client that asked for it. +#[derive(Default)] +pub struct BranchWatchers { + roots: Mutex>, +} + +impl BranchWatchers { + /// Report the branch of `root` now, and keep reporting on change. + /// Must run inside a Tokio runtime: the change reader is a task. + pub fn watch(&self, root: String, events: Arc) { + { + // The lookup and the insert happen under one guard: two clients + // watching the same root at once must share one watcher, not + // have the second overwrite the first with a count of one. + // `start_watcher` never awaits, so holding the lock is cheap. + let mut roots = self + .roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let watch = roots.entry(root.clone()).or_insert_with(|| BranchWatch { + watchers: 0, + watcher: None, + }); + watch.watchers += 1; + if watch.watcher.is_none() { + watch.watcher = start_watcher(&root, Arc::clone(&events)); + } + } + // Every client wants the current branch, whether or not the watch + // was already running. + publish_branch(&events, root); + } + + /// Drop one client's interest in `root`. The watcher stops with the + /// last one. + pub fn unwatch(&self, root: &str) { + let mut roots = self + .roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(watch) = roots.get_mut(root) { + watch.watchers = watch.watchers.saturating_sub(1); + if watch.watchers == 0 { + roots.remove(root); + } + } + } + + #[cfg(test)] + pub(crate) fn watched_roots(&self) -> Vec { + self.roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .keys() + .cloned() + .collect() + } + + /// `(clients, has a live watcher)` for `root`, when it is watched. + #[cfg(test)] + pub(crate) fn watch_state(&self, root: &str) -> Option<(usize, bool)> { + self.roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(root) + .map(|watch| (watch.watchers, watch.watcher.is_some())) + } +} + +/// Read the branch off the async workers and publish it. +fn publish_branch(events: &Arc, root: String) { + let events = Arc::clone(events); + tokio::task::spawn_blocking(move || { + let branch = branch_of(Path::new(&root)); + events.publish(HostEvent::ProjectBranch { + project_root: root, + branch, + }); + }); +} + +/// Watch the git dir of `root` and publish the branch when `HEAD` +/// changes. The watch is on the directory, not the file: git replaces +/// `HEAD` by rename, so a watch on the file itself is lost after the first +/// checkout. Non-recursive, so a busy `objects/` tree costs nothing. +/// Events arrive on the watcher's own thread and cross to a task through a +/// channel; a rebase or checkout touches `HEAD` several times in a row, so +/// one read per burst is enough. +fn start_watcher(root: &str, events: Arc) -> Option { + let git_dir = git_dir(Path::new(root))?; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut watcher = + match notify::recommended_watcher(move |event: notify::Result| { + let Ok(event) = event else { return }; + if head_change_event(&event) { + tx.send(()).ok(); + } + }) { + Ok(watcher) => watcher, + Err(error) => { + log::debug!("branch watcher unavailable: {error}"); + return None; + } + }; + if let Err(error) = watcher.watch(&git_dir, notify::RecursiveMode::NonRecursive) { + log::debug!("cannot watch {}: {error}", git_dir.display()); + return None; + } + let root = root.to_string(); + tokio::spawn(async move { + while rx.recv().await.is_some() { + while rx.try_recv().is_ok() {} + let branch = { + let root = root.clone(); + tokio::task::spawn_blocking(move || branch_of(Path::new(&root))) + .await + .unwrap_or(None) + }; + events.publish(HostEvent::ProjectBranch { + project_root: root.clone(), + branch, + }); + } + }); + Some(watcher) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("maple-git-{tag}-{}", uuid_like())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn uuid_like() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ^ (std::process::id() as u128) + } + + #[test] + fn branch_reads_refs_detached_heads_and_worktree_pointers() { + let root = temp_root("branch"); + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/feature/x\n").unwrap(); + assert_eq!(branch_of(&root).as_deref(), Some("feature/x")); + + std::fs::write(git.join("HEAD"), "0123abcdef0123abcdef\n").unwrap(); + assert_eq!(branch_of(&root).as_deref(), Some("0123abc")); + + std::fs::write(git.join("HEAD"), "garbage\n").unwrap(); + assert_eq!(branch_of(&root), None); + + let worktree = temp_root("worktree"); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git.display()), + ) + .unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + assert_eq!(branch_of(&worktree).as_deref(), Some("main")); + + let plain = temp_root("plain"); + assert_eq!(branch_of(&plain), None); + for dir in [root, worktree, plain] { + let _ = std::fs::remove_dir_all(dir); + } + } + + /// Issue #945: the branch watcher must ignore access-only HEAD events + /// (open, read, close). The branch read they trigger emits those same + /// events again under Linux inotify, looping at ~200% CPU while idle. + #[test] + fn head_change_ignores_access_events() { + use notify::EventKind; + use notify::event::{ + AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind, + RemoveKind, RenameMode, + }; + + let head = PathBuf::from("/repo/.git/HEAD"); + let event = |kind: EventKind| notify::Event::new(kind).add_path(head.clone()); + + // The read side of the loop, as emitted by Linux inotify. + for kind in [ + EventKind::Access(AccessKind::Open(AccessMode::Read)), + EventKind::Access(AccessKind::Read), + EventKind::Access(AccessKind::Close(AccessMode::Read)), + EventKind::Access(AccessKind::Close(AccessMode::Write)), + EventKind::Access(AccessKind::Any), + EventKind::Access(AccessKind::Other), + ] { + assert!(!head_change_event(&event(kind)), "access {kind:?}"); + } + + // Real changes still refresh the label: in-place write, create, + // remove, and the rename pair of an atomic replacement, plus the + // unclassified kinds imprecise backends emit for real changes. + for kind in [ + EventKind::Modify(ModifyKind::Data(DataChange::Content)), + EventKind::Modify(ModifyKind::Any), + EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)), + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + EventKind::Modify(ModifyKind::Name(RenameMode::Both)), + EventKind::Modify(ModifyKind::Name(RenameMode::Any)), + EventKind::Create(CreateKind::File), + EventKind::Create(CreateKind::Any), + EventKind::Remove(RemoveKind::File), + EventKind::Remove(RemoveKind::Any), + EventKind::Any, + EventKind::Other, + ] { + assert!(head_change_event(&event(kind)), "change {kind:?}"); + } + + // Unrelated paths never refresh, even with a change kind. + let unrelated = + notify::Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) + .add_path(PathBuf::from("/repo/.git/index")); + assert!(!head_change_event(&unrelated)); + + // A required rescan refreshes even without a HEAD path. + let event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert!(head_change_event(&event)); + } + + #[tokio::test] + async fn watchers_are_shared_and_dropped_with_the_last_client() { + let root = temp_root("shared"); + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let hub = Arc::new(HostEventHub::default()); + let mut rx = hub.subscribe(); + let watchers = BranchWatchers::default(); + let path = root.to_string_lossy().to_string(); + watchers.watch(path.clone(), Arc::clone(&hub)); + watchers.watch(path.clone(), Arc::clone(&hub)); + assert_eq!(watchers.watched_roots(), vec![path.clone()]); + assert_eq!(watchers.watch_state(&path), Some((2, true))); + // Both watch calls report the branch. + for _ in 0..2 { + let event = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + event, + HostEvent::ProjectBranch { branch: Some(ref branch), .. } if branch == "main" + )); + } + watchers.unwatch(&path); + assert_eq!(watchers.watch_state(&path), Some((1, true))); + watchers.unwatch(&path); + assert!(watchers.watched_roots().is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + /// Two clients that start watching one root at the same moment share + /// one watcher, and the first to leave does not take it with them. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_watches_of_one_root_share_the_watcher() { + let root = temp_root("concurrent"); + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let hub = Arc::new(HostEventHub::default()); + let mut rx = hub.subscribe(); + let watchers = Arc::new(BranchWatchers::default()); + let path = root.to_string_lossy().to_string(); + let tasks: Vec<_> = (0..2) + .map(|_| { + let watchers = Arc::clone(&watchers); + let hub = Arc::clone(&hub); + let path = path.clone(); + tokio::spawn(async move { watchers.watch(path, hub) }) + }) + .collect(); + for task in tasks { + task.await.unwrap(); + } + assert_eq!(watchers.watch_state(&path), Some((2, true))); + watchers.unwatch(&path); + assert_eq!(watchers.watch_state(&path), Some((1, true))); + + // The surviving watcher still reports a checkout. + std::fs::write(git.join("HEAD"), "ref: refs/heads/feature\n").unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let event = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("branch change reported") + .unwrap(); + if matches!( + event, + HostEvent::ProjectBranch { branch: Some(ref branch), .. } if branch == "feature" + ) { + break; + } + } + watchers.unwatch(&path); + assert!(watchers.watched_roots().is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + /// A root that becomes a checkout after its first watch gets a watcher + /// on the next watch instead of staying blind forever. + #[tokio::test] + async fn a_later_watch_attaches_a_watcher_once_the_root_is_a_checkout() { + let root = temp_root("late"); + let hub = Arc::new(HostEventHub::default()); + let watchers = BranchWatchers::default(); + let path = root.to_string_lossy().to_string(); + watchers.watch(path.clone(), Arc::clone(&hub)); + assert_eq!(watchers.watch_state(&path), Some((1, false))); + + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + watchers.watch(path.clone(), Arc::clone(&hub)); + assert_eq!(watchers.watch_state(&path), Some((2, true))); + watchers.unwatch(&path); + watchers.unwatch(&path); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs new file mode 100644 index 000000000..213a1761a --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs @@ -0,0 +1,829 @@ +//! The in-process host: [`HostBackend`] over [`AgentRuntimeHandle`]. +//! +//! The local window uses this directly. A server that publishes the same +//! runtime to remote clients is a sibling consumer of the runtime handle, +//! not a layer over this type. The submodules are what only a local host +//! needs: the filesystem, the git dir, and the account's SQLite stores. + +mod directories; +mod git; +mod store; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::mpsc; + +use super::{ + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, PERMISSION_MODE_AUTO, PERMISSION_MODE_SMART_APPROVE, UsageSummary, + effective_harness_instructions, +}; +use crate::agent::{ + AgentConfig, AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, + AgentMcpServer, AgentPermissionDecision, AgentPermissionModeRequest, AgentPermissionResponse, + AgentProjectRootRegistration, AgentProjectTrustStatus, AgentQueueControlRequest, + AgentRenameSessionRequest, AgentRuntimeHandle, AgentRuntimeStatus, AgentSendMessageRequest, + AgentSessionDetail, AgentSessionIntegrationKind, AgentSessionMcpServer, AgentSessionSummary, + AgentSetIntegrationEnabledRequest, AgentSetSessionMcpServerRequest, AgentSetSessionWebRequest, + AgentSetupIntegrationRequest, AgentSlashCommand, AgentStartRequest, AgentSubagent, + AgentTaskState, MapleAgentService, RecentProjectRoot, SideQuestionTurn, + account_sessions_db_path, account_tool_summaries_db_path, +}; +use crate::maple_api::MapleApiSession; +use git::BranchWatchers; +use store::AccountStores; + +/// Where the local host gets the validated OpenSecret session it needs to +/// start the runtime and rename tasks. The app implements this over its +/// persisted sign-in; it waits for a background credential restore first. +#[async_trait] +pub trait LocalHostAuth: Send + Sync + 'static { + async fn api_session(&self, user_id: &str) -> Result, String>; +} + +/// Session defaults an older app kept in its own settings file. The local +/// host adopts them once into the account config; see +/// [`LocalHostBackend::migrate_session_defaults`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LegacySessionDefaults { + pub permission_mode: Option, + pub web_enabled: Option, + pub harness_instructions: Option, +} + +impl LegacySessionDefaults { + pub fn is_empty(&self) -> bool { + self.permission_mode.is_none() + && self.web_enabled.is_none() + && self.harness_instructions.is_none() + } +} + +/// Fallback context limit when the catalog lacks the model. +const DEFAULT_CONTEXT_LIMIT: i64 = 200_000; + +/// How long a runtime start may take before a wedged enclave connection +/// surfaces as an error instead of an eternal spinner. +const RUNTIME_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +pub struct LocalHostBackend { + id: HostId, + service: MapleAgentService, + user_id: String, + auth: Arc, + events: Arc, + stores: Arc, + branches: BranchWatchers, +} + +impl LocalHostBackend { + /// Bind the runtime to `user_id`. `events` must be the sink the + /// service was built with, so runtime events and host events share one + /// stream. The account's saved harness instructions reach the runtime + /// on [`Self::apply_saved_harness`], which the bootstrap and runtime + /// start call first. + pub fn new( + service: MapleAgentService, + user_id: String, + auth: Arc, + events: Arc, + ) -> Arc { + Arc::new(Self { + id: HostId::local(), + service, + user_id, + auth, + events, + stores: Arc::new(AccountStores::default()), + branches: BranchWatchers::default(), + }) + } + + /// Hand the account's saved harness instructions to the runtime. + /// Applies to agents built afterwards. + pub async fn apply_saved_harness(&self) -> Result<(), String> { + let config = self.handle().await?.load_config().await?; + self.apply_harness(&config); + Ok(()) + } + + pub fn user_id(&self) -> &str { + &self.user_id + } + + async fn handle(&self) -> Result { + self.service.handle_for_user(&self.user_id).await + } + + async fn api_session(&self) -> Result, String> { + self.auth.api_session(&self.user_id).await + } + + fn apply_harness(&self, config: &AgentConfig) { + self.service + .set_harness_instructions(effective_harness_instructions( + config.harness_instructions.as_deref().unwrap_or(""), + )); + } + + /// Adopt defaults from an older app settings file into the account + /// config, for each value the config never saved. Values the config + /// already holds win. + pub async fn migrate_session_defaults( + &self, + legacy: LegacySessionDefaults, + ) -> Result<(), String> { + if legacy.is_empty() { + return Ok(()); + } + let handle = self.handle().await?; + let mut config = handle.load_config().await?; + let mut changed = false; + if config.default_permission_mode.is_none() + && let Some(mode) = legacy.permission_mode + { + config.default_permission_mode = Some(normalize_permission_mode(&mode)); + changed = true; + } + if config.default_web_enabled.is_none() + && let Some(enabled) = legacy.web_enabled + { + config.default_web_enabled = Some(enabled); + changed = true; + } + if config.harness_instructions.is_none() + && let Some(text) = legacy.harness_instructions + { + config.harness_instructions = Some(text); + changed = true; + } + if changed { + log::info!("adopted session defaults from the app settings into the account config"); + handle.save_config(config.clone()).await?; + self.apply_harness(&config); + } + Ok(()) + } + + fn sessions_db_path(&self) -> Result { + account_sessions_db_path(self.service.paths(), &self.user_id) + } + + fn summaries_db_path(&self) -> Result { + account_tool_summaries_db_path(self.service.paths(), &self.user_id) + } +} + +/// `smart_approve` or `auto`; anything else is the safer mode. +fn normalize_permission_mode(mode: &str) -> String { + if mode == PERMISSION_MODE_AUTO { + PERMISSION_MODE_AUTO.to_string() + } else { + PERMISSION_MODE_SMART_APPROVE.to_string() + } +} + +fn session_defaults_from(config: &AgentConfig) -> HostSessionDefaults { + HostSessionDefaults { + permission_mode: config + .default_permission_mode + .as_deref() + .map(normalize_permission_mode) + .unwrap_or_else(|| PERMISSION_MODE_SMART_APPROVE.to_string()), + web_enabled: config.default_web_enabled.unwrap_or(true), + harness_instructions: config.harness_instructions.clone().unwrap_or_default(), + default_model: Some(config.default_model.clone()).filter(|model| !model.is_empty()), + } +} + +/// Root for a GUI start: the saved default when it still is a folder, else +/// the home directory. Never the process working directory: that is the +/// job of the `acp` command, not a windowed app started from a launcher. +fn gui_project_root(config: &AgentConfig) -> Option { + config + .default_project_root + .as_deref() + .filter(|path| !path.trim().is_empty() && std::path::Path::new(path).is_dir()) + .map(str::to_owned) + .or_else(|| dirs::home_dir().map(|path| path.to_string_lossy().to_string())) +} + +/// Tasks that an ACP client created belong to that client's UI, not to a +/// desktop-class client's task list. +fn without_acp_sessions(mut sessions: Vec) -> Vec { + sessions.retain(|session| !session.acp); + sessions +} + +#[async_trait] +impl HostBackend for LocalHostBackend { + fn id(&self) -> &HostId { + &self.id + } + + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + + async fn bootstrap(&self) -> Result { + let handle = self.handle().await?; + let config = handle.load_config().await?; + self.apply_harness(&config); + let project_root = gui_project_root(&config); + let sessions = without_acp_sessions(handle.list_sessions(None).await?); + let recent_roots = handle + .list_recent_project_roots() + .await? + .into_iter() + .map(|root| root.path) + .collect(); + // Same choice a session refresh makes: the newest unarchived task + // under the root that the runtime will start in. + let latest_id = sessions + .iter() + .find(|session| { + // An empty task is a draft an older build persisted; the + // client's own draft screen stands in for it. + session.state != AgentTaskState::Archived + && session.message_count > 0 + && Some(&session.project_root) == project_root.as_ref() + }) + .map(|session| session.id.clone()); + let latest = match latest_id { + Some(id) => match handle.load_session(id.clone()).await { + Ok(detail) => Some(detail), + Err(error) => { + // Bootstrap still succeeds without the transcript; the + // client loads it on demand and sees the error then. + log::debug!("bootstrap could not load the latest task {id}: {error}"); + None + } + }, + None => None, + }; + Ok(HostBootstrap { + project_root, + sessions, + recent_roots, + latest, + session_defaults: session_defaults_from(&config), + }) + } + + async fn start_runtime( + &self, + request: Option, + ) -> Result { + let handle = self.handle().await?; + let config = handle.load_config().await?; + self.apply_harness(&config); + let session = self.api_session().await?; + // The agent falls back to the process working directory when no + // root is given. That is right for `acp`, not for a client. + let request = match request { + Some(AgentStartRequest { + project_root: None, + model, + mode, + }) => Some(AgentStartRequest { + project_root: gui_project_root(&config), + model, + mode, + }), + other => other, + }; + tokio::time::timeout(RUNTIME_START_TIMEOUT, handle.start(session, request)) + .await + .map_err(|_| "Runtime start timed out. Check your connection and retry.".to_string())? + } + + async fn stop_runtime(&self) -> Result { + self.handle().await?.stop().await + } + + async fn recent_project_roots(&self) -> Result, String> { + self.handle().await?.list_recent_project_roots().await + } + + async fn select_project_root( + &self, + path: String, + ) -> Result { + self.handle().await?.save_recent_project_root(path).await + } + + async fn remove_project_root( + &self, + path: String, + fallback: Option, + ) -> Result<(), String> { + self.handle() + .await? + .remove_project_root(path, fallback) + .await + .map(|_| ()) + } + + async fn suggest_directories(&self, query: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + directories::suggest(&query, dirs::home_dir().as_deref()) + }) + .await + .map_err(|error| format!("Directory listing failed: {error}")) + } + + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.branches.watch(path, Arc::clone(&self.events)); + Ok(()) + } + + async fn unwatch_project_root(&self, path: String) -> Result<(), String> { + self.branches.unwatch(&path); + Ok(()) + } + + async fn project_trust(&self, path: String) -> Result { + self.handle().await?.get_project_trust(path).await + } + + async fn set_project_trust( + &self, + path: String, + trusted: bool, + ) -> Result { + self.handle().await?.set_project_trust(path, trusted).await + } + + async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String> { + Ok(without_acp_sessions( + self.handle().await?.list_sessions(project_root).await?, + )) + } + + async fn create_session( + &self, + request: Option, + ) -> Result { + self.handle().await?.create_session(request).await + } + + async fn load_session(&self, session_id: String) -> Result { + self.handle().await?.load_session(session_id).await + } + + async fn rename_session( + &self, + session_id: String, + title: String, + ) -> Result { + let handle = self.handle().await?; + let session = self.api_session().await?; + handle + .rename_session(session, AgentRenameSessionRequest { session_id, title }) + .await + } + + async fn set_session_state( + &self, + session_id: String, + state: AgentTaskState, + ) -> Result { + self.handle() + .await? + .set_session_state(session_id, state) + .await + } + + async fn delete_session(&self, session_id: String) -> Result<(), String> { + self.handle().await?.delete_session(session_id).await + } + + async fn compact_session(&self, session_id: String) -> Result<(), String> { + self.handle().await?.compact_session(session_id).await + } + + async fn session_subagents(&self, session_id: String) -> Result, String> { + Ok(self.handle().await?.session_subagents(&session_id).await) + } + + async fn cancel_external_agent( + &self, + session_id: String, + agent_id: String, + ) -> Result<(), String> { + self.handle() + .await? + .cancel_external_agent(&session_id, &agent_id) + .await + } + + async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String> { + self.handle() + .await? + .set_permission_mode(AgentPermissionModeRequest { session_id, mode }) + .await + } + + async fn set_session_web_enabled( + &self, + session_id: String, + enabled: bool, + ) -> Result { + self.handle() + .await? + .set_session_web_enabled(AgentSetSessionWebRequest { + session_id, + enabled, + }) + .await + } + + /// Latest context usage for a session from the goose usage ledger. + /// The limit comes from the model catalog for the selected model; + /// `MAPLE_CONTEXT_LIMIT` is a manual override; 200k is the fallback + /// when the catalog lacks the model. + async fn context_usage( + &self, + session_id: String, + model: Option, + ) -> Result, String> { + let limit: i64 = match std::env::var("MAPLE_CONTEXT_LIMIT") + .ok() + .and_then(|value| value.parse().ok()) + { + Some(limit) if limit > 0 => limit, + _ => match model { + Some(model) => self + .handle() + .await? + .context_limit_for_model(&model) + .await? + .unwrap_or(DEFAULT_CONTEXT_LIMIT), + None => DEFAULT_CONTEXT_LIMIT, + }, + }; + let db = self.sessions_db_path()?; + let stores = Arc::clone(&self.stores); + // SQLite is synchronous; keep it off the async workers. + let tokens = tokio::task::spawn_blocking(move || { + stores + .with_usage_db(&db, |conn| store::latest_context_tokens(conn, &session_id)) + .flatten() + }) + .await + .map_err(|error| format!("Context usage query failed: {error}"))?; + Ok(tokens.map(|tokens| ContextUsage { tokens, limit })) + } + + async fn read_image_attachment( + &self, + session_id: String, + attachment_id: String, + ) -> Result, String> { + self.handle() + .await? + .read_image_attachment(session_id, attachment_id) + .await + } + + async fn send_message(&self, request: AgentSendMessageRequest) -> Result { + Ok(self.handle().await?.send_message(request).await?.run_id) + } + + async fn cancel_run(&self, run_id: String) -> Result<(), String> { + self.handle().await?.cancel_desktop_run(run_id).await + } + + async fn cancel_queued_message( + &self, + session_id: String, + queue_id: String, + ) -> Result { + self.handle() + .await? + .cancel_queued_message(AgentQueueControlRequest { + session_id, + queue_id, + }) + .await + } + + async fn begin_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.handle() + .await? + .begin_queued_message_edit(AgentQueueControlRequest { + session_id, + queue_id, + }) + .await + } + + async fn end_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.handle() + .await? + .end_queued_message_edit(AgentQueueControlRequest { + session_id, + queue_id, + }) + .await + } + + async fn answer_question(&self, request_id: String, answer: String) -> Result { + self.handle() + .await? + .answer_question_via_handle(&request_id, answer) + .await + } + + async fn permission_respond( + &self, + session_id: String, + request_id: String, + allow: bool, + ) -> Result<(), String> { + self.handle() + .await? + .permission_respond(AgentPermissionResponse { + session_id, + request_id, + decision: if allow { + AgentPermissionDecision::AllowOnce + } else { + AgentPermissionDecision::DenyOnce + } + .as_str() + .to_string(), + }) + .await + } + + async fn ask_side_question( + &self, + session_id: String, + request_id: String, + prior: Vec, + question: String, + ) -> Result<(), String> { + self.handle() + .await? + .ask_side_question(&session_id, request_id, prior, question) + .await + } + + async fn summarize_tool_call( + &self, + session_id: String, + tool_name: String, + input: Option, + output_text: String, + ) -> Result, String> { + self.handle() + .await? + .summarize_tool_call(&session_id, &tool_name, input.as_ref(), &output_text) + .await + } + + async fn summarize_thinking( + &self, + session_id: String, + thinking_text: String, + ) -> Result, String> { + self.handle() + .await? + .summarize_thinking(&session_id, &thinking_text) + .await + } + + async fn tool_summaries(&self, session_id: String) -> Result, String> { + let db = self.summaries_db_path()?; + let stores = Arc::clone(&self.stores); + tokio::task::spawn_blocking(move || { + stores.with_summary_db(&db, |conn| store::load_tool_summaries(conn, &session_id)) + }) + .await + .map_err(|error| format!("Tool summary read failed: {error}"))? + } + + async fn store_tool_summary( + &self, + session_id: String, + item_id: String, + summary: String, + ) -> Result<(), String> { + let db = self.summaries_db_path()?; + let stores = Arc::clone(&self.stores); + tokio::task::spawn_blocking(move || { + stores.with_summary_db(&db, |conn| { + store::store_tool_summary(conn, &session_id, &item_id, &summary) + }) + }) + .await + .map_err(|error| format!("Tool summary write failed: {error}"))? + } + + async fn available_model_ids(&self) -> Result, String> { + self.handle().await?.available_model_ids().await + } + + async fn model_supports_vision(&self, model: String) -> Result, String> { + self.handle().await?.model_supports_vision(&model).await + } + + /// Filesystem scan, so it runs on a blocking thread. + async fn list_slash_commands( + &self, + working_dir: Option, + ) -> Result, String> { + let service = self.service.clone(); + let user_id = self.user_id.clone(); + tokio::task::spawn_blocking(move || { + service.list_slash_commands(Some(&user_id), working_dir.as_deref()) + }) + .await + .map_err(|error| format!("Slash command scan failed: {error}")) + } + + async fn resolve_slash_command( + &self, + working_dir: Option, + command: String, + args: String, + ) -> Result, String> { + let service = self.service.clone(); + tokio::task::spawn_blocking(move || { + service.resolve_slash_command(working_dir.as_deref(), &command, &args) + }) + .await + .map_err(|error| format!("Slash command resolve failed: {error}"))? + } + + async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String> { + self.handle() + .await? + .list_session_mcp_servers(session_id) + .await + } + + async fn set_session_mcp_server_enabled( + &self, + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + ) -> Result, String> { + self.handle() + .await? + .set_session_mcp_server_enabled(AgentSetSessionMcpServerRequest { + session_id, + name, + kind, + enabled, + }) + .await + } + + async fn list_mcp_servers(&self) -> Result, String> { + self.handle().await?.list_mcp_servers().await + } + + async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String> { + self.handle().await?.save_mcp_servers(servers).await + } + + async fn list_integrations(&self) -> Result, String> { + self.handle().await?.list_integrations().await + } + + async fn set_integration_enabled( + &self, + id: String, + enabled: bool, + ) -> Result, String> { + self.handle() + .await? + .set_integration_enabled(AgentSetIntegrationEnabledRequest { id, enabled }) + .await + } + + async fn setup_integration(&self, id: String) -> Result, String> { + self.handle() + .await? + .setup_integration(AgentSetupIntegrationRequest { id }) + .await + } + + async fn session_defaults(&self) -> Result { + let config = self.handle().await?.load_config().await?; + Ok(session_defaults_from(&config)) + } + + /// Save the settings-screen defaults. `default_model` is not among + /// them: the chat screen owns it through [`Self::save_default_model`], + /// and a settings snapshot taken before a model change would put the + /// old model back if it were written here. + async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String> { + let handle = self.handle().await?; + let mut config = handle.load_config().await?; + config.default_permission_mode = Some(normalize_permission_mode(&defaults.permission_mode)); + config.default_web_enabled = Some(defaults.web_enabled); + config.harness_instructions = Some(defaults.harness_instructions); + handle.save_config(config.clone()).await?; + self.apply_harness(&config); + Ok(()) + } + + async fn save_default_model(&self, model: String) -> Result<(), String> { + let handle = self.handle().await?; + let mut config = handle.load_config().await?; + config.default_model = model; + handle.save_config(config).await + } + + async fn usage_summary(&self) -> Result { + let db = self.sessions_db_path()?; + let stores = Arc::clone(&self.stores); + tokio::task::spawn_blocking(move || { + stores + .with_usage_db(&db, store::usage_from_ledger) + .unwrap_or_default() + }) + .await + .map_err(|error| format!("Usage query failed: {error}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_defaults_read_the_config_with_safe_fallbacks() { + let mut config = AgentConfig::default(); + let defaults = session_defaults_from(&config); + assert_eq!(defaults.permission_mode, PERMISSION_MODE_SMART_APPROVE); + assert!(defaults.web_enabled); + assert_eq!(defaults.harness_instructions, ""); + assert!(defaults.default_model.is_some()); + + config.default_permission_mode = Some("auto".to_string()); + config.default_web_enabled = Some(false); + config.harness_instructions = Some("custom".to_string()); + config.default_model = String::new(); + let defaults = session_defaults_from(&config); + assert_eq!(defaults.permission_mode, PERMISSION_MODE_AUTO); + assert!(!defaults.web_enabled); + assert_eq!(defaults.harness_instructions, "custom"); + assert_eq!(defaults.default_model, None); + + config.default_permission_mode = Some("garbage".to_string()); + assert_eq!( + session_defaults_from(&config).permission_mode, + PERMISSION_MODE_SMART_APPROVE + ); + } + + #[test] + fn acp_sessions_stay_out_of_client_lists() { + let mut acp = sample_session("acp"); + acp.acp = true; + let kept = without_acp_sessions(vec![sample_session("desktop"), acp]); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "desktop"); + } + + fn sample_session(id: &str) -> AgentSessionSummary { + AgentSessionSummary { + id: id.to_string(), + title: id.to_string(), + project_root: "/p".to_string(), + created_ms: 0, + updated_ms: 0, + message_count: 0, + model: None, + mode: "smart_approve".to_string(), + web_enabled: true, + state: AgentTaskState::Active, + acp: false, + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/local/store.rs b/apps/maple-agent/crates/maple-agent/src/host/local/store.rs new file mode 100644 index 000000000..b98097247 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/local/store.rs @@ -0,0 +1,314 @@ +//! Host-side readers of the account stores. +//! +//! The Goose usage ledger is owned and written by the runtime; this module +//! opens it read-only. The tool summary store is owned by the host and +//! written here. Both are SQLite, so every call is blocking and runs on a +//! blocking thread. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::host::{UsageRow, UsageSummary}; + +/// Open the goose sessions database for reading. Returns `None` when the +/// file does not exist yet (read-only open never creates it). The busy +/// timeout covers the short locks goose takes for WAL checkpoints. +fn open_session_db_read_only(path: &Path) -> Option { + use rusqlite::OpenFlags; + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_URI; + let conn = match rusqlite::Connection::open_with_flags(path, flags) { + Ok(conn) => conn, + Err(error) => { + if path.exists() { + log::warn!("Cannot open session db {}: {error}", path.display()); + } + return None; + } + }; + if let Err(error) = conn.busy_timeout(std::time::Duration::from_secs(5)) { + log::warn!("Cannot set busy timeout on {}: {error}", path.display()); + } + Some(conn) +} + +/// Open (and create) the tool summary store. +fn open_summary_db(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("Cannot create {}: {error}", parent.display()))?; + } + let conn = rusqlite::Connection::open(path) + .map_err(|error| format!("Cannot open {}: {error}", path.display()))?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; \ + CREATE TABLE IF NOT EXISTS tool_summaries ( \ + session_id TEXT NOT NULL, \ + item_id TEXT NOT NULL, \ + summary TEXT NOT NULL, \ + PRIMARY KEY (session_id, item_id) \ + );", + ) + .map_err(|error| format!("Cannot init {}: {error}", path.display()))?; + Ok(conn) +} + +/// Open handles to one account's stores. The usage ledger is polled every +/// second during a run, so its connection is kept open rather than +/// reopened per query. +#[derive(Default)] +pub(super) struct AccountStores { + usage_db: Mutex>, + summary_db: Mutex>, +} + +impl AccountStores { + /// Run `f` against the usage ledger at `path`, or `None` when the + /// ledger does not exist yet. Blocking. + pub(super) fn with_usage_db( + &self, + path: &Path, + f: impl FnOnce(&rusqlite::Connection) -> T, + ) -> Option { + let mut guard = self + .usage_db + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if guard.as_ref().map(|(open, _)| open != path).unwrap_or(true) { + let conn = open_session_db_read_only(path)?; + *guard = Some((path.to_path_buf(), conn)); + } + Some(f(&guard.as_ref().expect("usage db opened above").1)) + } + + /// Run `f` against the summary store at `path`. Blocking. + pub(super) fn with_summary_db( + &self, + path: &Path, + f: impl FnOnce(&rusqlite::Connection) -> Result, + ) -> Result { + let mut guard = self + .summary_db + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if guard.as_ref().map(|(open, _)| open != path).unwrap_or(true) { + *guard = Some((path.to_path_buf(), open_summary_db(path)?)); + } + f(&guard.as_ref().expect("summary db opened above").1) + .map_err(|error| format!("{error} ({})", path.display())) + } +} + +/// Latest context tokens for a session from the usage ledger: input plus +/// cache reads and writes of the newest non-compaction row. +pub(super) fn latest_context_tokens(conn: &rusqlite::Connection, session_id: &str) -> Option { + conn.query_row( + "SELECT COALESCE(input_tokens,0) + COALESCE(cache_read_tokens,0) \ + + COALESCE(cache_write_tokens,0) FROM usage_ledger \ + WHERE session_id = ?1 AND is_compaction = 0 \ + ORDER BY id DESC LIMIT 1", + [session_id], + |row| row.get::<_, i64>(0), + ) + .ok() +} + +pub(super) fn load_tool_summaries( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare("SELECT item_id, summary FROM tool_summaries WHERE session_id = ?1") + .map_err(|error| format!("Cannot prepare the tool summary query: {error}"))?; + let rows = stmt + .query_map([session_id], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|error| format!("Cannot read tool summaries for task {session_id}: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("Cannot read tool summaries for task {session_id}: {error}")) +} + +pub(super) fn store_tool_summary( + conn: &rusqlite::Connection, + session_id: &str, + item_id: &str, + summary: &str, +) -> Result<(), String> { + conn.execute( + "INSERT OR REPLACE INTO tool_summaries (session_id, item_id, summary) \ + VALUES (?1, ?2, ?3)", + [session_id, item_id, summary], + ) + .map(|_| ()) + .map_err(|error| format!("Cannot store the tool summary for task {session_id}: {error}")) +} + +/// Aggregate one account's ledger. +/// +/// A subagent has a session of its own, and its provider calls land in +/// the ledger under it. Every row counts against the task that delegated +/// the work, so the reader sees what a task cost in total. Goose refuses +/// a subagent of a subagent, so resolving one parent is enough. +pub(super) fn usage_from_ledger(conn: &rusqlite::Connection) -> UsageSummary { + let mut summary = UsageSummary::default(); + + if let Ok(mut stmt) = conn.prepare( + "SELECT COUNT(*), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cost),0) \ + FROM usage_ledger", + ) && let Ok(row) = stmt.query_row([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, f64>(2)?, + )) + }) { + summary.totals = UsageRow { + label: "All activity".to_string(), + sessions: 0, + turns: row.0.max(0) as u64, + total_tokens: row.1, + cost: row.2, + }; + } + + if let Ok(mut stmt) = conn.prepare( + "SELECT u.model, COUNT(DISTINCT COALESCE(s.parent_session_id, u.session_id)), COUNT(*), \ + COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ + FROM usage_ledger u LEFT JOIN sessions s ON s.id = u.session_id \ + GROUP BY u.model ORDER BY SUM(u.total_tokens) DESC", + ) && let Ok(rows) = stmt.query_map([], |row| { + Ok(UsageRow { + label: row + .get::<_, Option>(0)? + .unwrap_or_else(|| "unknown".into()), + sessions: row.get::<_, i64>(1)?.max(0) as u64, + turns: row.get::<_, i64>(2)?.max(0) as u64, + total_tokens: row.get::<_, i64>(3)?, + cost: row.get::<_, f64>(4)?, + }) + }) { + for row in rows.flatten() { + summary.totals.sessions += row.sessions; + summary.by_model.push(row); + } + } + + if let Ok(mut stmt) = conn.prepare( + "SELECT COALESCE(parent.name, s.name), COALESCE(s.parent_session_id, u.session_id) AS task, \ + COUNT(*), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ + FROM usage_ledger u JOIN sessions s ON s.id = u.session_id \ + LEFT JOIN sessions parent ON parent.id = s.parent_session_id \ + GROUP BY task ORDER BY MAX(u.created_timestamp) DESC LIMIT 20", + ) && let Ok(rows) = stmt.query_map([], |row| { + Ok(UsageRow { + label: { + let name: String = row.get::<_, Option>(0)?.unwrap_or_default(); + let id: String = row.get(1)?; + if name.trim().is_empty() { id } else { name } + }, + sessions: 1, + turns: row.get::<_, i64>(2)?.max(0) as u64, + total_tokens: row.get::<_, i64>(3)?, + cost: row.get::<_, f64>(4)?, + }) + }) { + for row in rows.flatten() { + summary.by_session.push(row); + } + } + + summary +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A subagent bills to the task that delegated the work, so the + /// usage screen shows one row per task and not one per subagent. + #[test] + fn subagent_usage_counts_against_its_parent_task() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + parent_session_id TEXT + ); + CREATE TABLE usage_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_timestamp INTEGER NOT NULL, + model TEXT, + total_tokens INTEGER, + cost REAL + ); + INSERT INTO sessions VALUES ('task-1', 'Review the parser', NULL); + INSERT INTO sessions VALUES ('sub-1', 'Delegated task', 'task-1'); + INSERT INTO sessions VALUES ('task-2', 'Other work', NULL); + INSERT INTO usage_ledger (session_id, created_timestamp, model, total_tokens, cost) + VALUES ('task-1', 10, 'maple-1', 100, 1.0), + ('sub-1', 20, 'maple-1', 400, 4.0), + ('task-2', 30, 'maple-1', 700, 7.0);", + ) + .unwrap(); + + let usage = usage_from_ledger(&conn); + let rows = usage + .by_session + .iter() + .map(|row| (row.label.as_str(), row.turns, row.total_tokens)) + .collect::>(); + assert_eq!( + rows, + vec![("Other work", 1, 700), ("Review the parser", 2, 500)], + "the subagent's tokens belong to the task that delegated them" + ); + // Two tasks ran, not three sessions. + assert_eq!(usage.by_model.len(), 1); + assert_eq!(usage.by_model[0].sessions, 2); + assert_eq!(usage.totals.total_tokens, 1200); + } + + #[test] + fn tool_summaries_round_trip_and_replace() { + let dir = std::env::temp_dir().join(format!( + "maple-summaries-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let path = dir.join("tool_summaries.db"); + let stores = AccountStores::default(); + stores + .with_summary_db(&path, |conn| store_tool_summary(conn, "s1", "i1", "first")) + .unwrap(); + stores + .with_summary_db(&path, |conn| store_tool_summary(conn, "s1", "i1", "second")) + .unwrap(); + stores + .with_summary_db(&path, |conn| store_tool_summary(conn, "s2", "i9", "other")) + .unwrap(); + let loaded = stores + .with_summary_db(&path, |conn| load_tool_summaries(conn, "s1")) + .unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded.get("i1").map(String::as_str), Some("second")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_missing_ledger_reads_as_none() { + let stores = AccountStores::default(); + let missing = std::env::temp_dir() + .join("maple-no-such-dir") + .join("sessions.db"); + assert!( + stores + .with_usage_db(&missing, |conn| latest_context_tokens(conn, "s")) + .is_none() + ); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/mod.rs new file mode 100644 index 000000000..1a7924bdb --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/mod.rs @@ -0,0 +1,488 @@ +//! The host boundary: everything a client drives on a host. +//! +//! A host runs the agent runtime. A client is the desktop app. Every app +//! instance is its own local host, and it can also be a client of remote +//! hosts. The [`HostBackend`] trait is the surface a client calls; the +//! [`HostEvent`] stream is what a host pushes back. [`LocalHostBackend`] +//! implements the trait in process over [`crate::agent::AgentRuntimeHandle`]. +//! A remote implementation speaks the same trait over the wire, so the UI +//! never branches on where a host runs. +//! +//! Account-level concerns (sign-in, billing, audio) are not part of this +//! surface: they use the client's own OpenSecret session and stay local. + +pub mod local; + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentEventSink, AgentIntegration, + AgentMcpServer, AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, AgentTaskState, RecentProjectRoot, SideQuestionTurn, +}; + +pub use local::{LegacySessionDefaults, LocalHostAuth, LocalHostBackend}; + +/// Identifies a host on the client. The local host is [`HostId::local`]; +/// a remote host is identified by its static public key. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HostId(String); + +impl HostId { + /// The id every client uses for its own in-process host. + pub const LOCAL: &'static str = "local"; + + pub fn local() -> Self { + Self(Self::LOCAL.to_string()) + } + + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_local(&self) -> bool { + self.0 == Self::LOCAL + } +} + +impl std::fmt::Display for HostId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Everything a host pushes to its clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HostEvent { + /// An event from the agent runtime. Boxed: it is far larger than the + /// other variants, and every event is cloned once per subscriber. + Service(Box), + /// The git branch of a watched project root, sent when a watch starts + /// and whenever `HEAD` changes. `None` when the root is not a checkout. + ProjectBranch { + project_root: String, + branch: Option, + }, + /// Events may have been missed: a remote connection saw a gap in the + /// host's sequence, or reconnected. The client re-reads the task list + /// and reloads any task it shows. The local host never sends this. + Resync, +} + +/// Opening system prompt text used when a host has none saved. +pub const DEFAULT_HARNESS_INSTRUCTIONS: &str = + "You are a general-purpose AI agent called Maple, created by Maple AI. +You run in the Maple app's Agent Mode; users know you simply as Maple."; + +// The permission policy names are the runtime's; the host speaks them on +// the wire unchanged. +pub use crate::agent::{PERMISSION_MODE_AUTO, PERMISSION_MODE_SMART_APPROVE}; + +/// Defaults a host applies to the tasks its clients create. Stored in the +/// host's per-account config, so two hosts can differ. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostSessionDefaults { + /// `smart_approve` or `auto`. + pub permission_mode: String, + /// Whether new tasks can use the web tools. + pub web_enabled: bool, + /// Opening system prompt text. Empty means + /// [`DEFAULT_HARNESS_INSTRUCTIONS`]. + pub harness_instructions: String, + /// The account's saved default model, if any. Read-only here: the + /// chat screen saves it through [`HostBackend::save_default_model`], + /// and [`HostBackend::set_session_defaults`] leaves it alone so a + /// stale settings snapshot cannot put an old model back. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + +impl Default for HostSessionDefaults { + fn default() -> Self { + Self { + permission_mode: PERMISSION_MODE_SMART_APPROVE.to_string(), + web_enabled: true, + harness_instructions: String::new(), + default_model: None, + } + } +} + +impl HostSessionDefaults { + /// The harness instructions to hand the runtime: the saved text, or + /// the default when nothing is saved. + pub fn effective_harness_instructions(&self) -> String { + effective_harness_instructions(&self.harness_instructions) + } +} + +/// The harness instructions for a saved value: the text, or the default +/// when it is blank. +pub fn effective_harness_instructions(saved: &str) -> String { + let saved = saved.trim(); + if saved.is_empty() { + DEFAULT_HARNESS_INSTRUCTIONS.to_string() + } else { + saved.to_string() + } +} + +/// Everything a client can show for a host before any network call: the +/// saved project root, the task list, the recent roots, the newest task's +/// transcript, and the session defaults. Read in one call so it all lands +/// before a runtime start takes the lifecycle lock. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostBootstrap { + pub project_root: Option, + pub sessions: Vec, + pub recent_roots: Vec, + pub latest: Option, + pub session_defaults: HostSessionDefaults, +} + +/// Context window use for one task, from the host's usage ledger. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextUsage { + pub tokens: i64, + pub limit: i64, +} + +/// One directory a typed root could mean; see +/// [`HostBackend::suggest_directories`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectorySuggestion { + /// Absolute path. + pub path: String, + /// Last path component, for display. + pub name: String, +} + +/// One aggregated usage row: per session or per model. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageRow { + pub label: String, + pub sessions: u64, + pub turns: u64, + pub total_tokens: i64, + pub cost: f64, +} + +/// The account's usage ledger, aggregated; see +/// [`HostBackend::usage_summary`]. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSummary { + pub totals: UsageRow, + pub by_model: Vec, + pub by_session: Vec, +} + +/// Fans one host's events out to every subscriber. The runtime's event +/// sink for the local host; a server projects the same stream to its +/// sockets. Subscribers that dropped their receiver are pruned on the +/// next publish. +#[derive(Default)] +pub struct HostEventHub { + subscribers: Mutex>>, +} + +impl HostEventHub { + pub fn subscribe(&self) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + self.subscribers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(tx); + rx + } + + pub fn publish(&self, event: HostEvent) { + let mut subscribers = self + .subscribers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + subscribers.retain(|subscriber| subscriber.send(event.clone()).is_ok()); + } + + #[cfg(test)] + pub(crate) fn subscriber_count(&self) -> usize { + self.subscribers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } +} + +impl AgentEventSink for HostEventHub { + fn emit(&self, event: &AgentServiceEvent) { + self.publish(HostEvent::Service(Box::new(event.clone()))); + } +} + +/// What a client drives on one host. Every method is scoped to the one +/// account the host is signed in as. +#[async_trait] +pub trait HostBackend: Send + Sync + 'static { + fn id(&self) -> &HostId; + + /// A fresh stream of this host's events. Several subscribers may be + /// live at once; each receives every event. + fn subscribe(&self) -> mpsc::UnboundedReceiver; + + // ---- Runtime ----------------------------------------------------------- + + async fn bootstrap(&self) -> Result; + async fn start_runtime( + &self, + request: Option, + ) -> Result; + async fn stop_runtime(&self) -> Result; + + // ---- Projects ---------------------------------------------------------- + + async fn recent_project_roots(&self) -> Result, String>; + async fn select_project_root( + &self, + path: String, + ) -> Result; + async fn remove_project_root( + &self, + path: String, + fallback: Option, + ) -> Result<(), String>; + /// Directories on the host that complete `query`, for a typed root. + async fn suggest_directories(&self, query: String) -> Result, String>; + /// Start reporting the git branch of `path` through + /// [`HostEvent::ProjectBranch`]; the first report follows at once. + async fn watch_project_root(&self, path: String) -> Result<(), String>; + async fn unwatch_project_root(&self, path: String) -> Result<(), String>; + async fn project_trust(&self, path: String) -> Result; + async fn set_project_trust( + &self, + path: String, + trusted: bool, + ) -> Result; + + // ---- Sessions ---------------------------------------------------------- + + async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String>; + async fn create_session( + &self, + request: Option, + ) -> Result; + async fn load_session(&self, session_id: String) -> Result; + async fn rename_session( + &self, + session_id: String, + title: String, + ) -> Result; + /// Move a task between active, settled, and archived. The runtime + /// owns the state and answers with the updated record. + async fn set_session_state( + &self, + session_id: String, + state: AgentTaskState, + ) -> Result; + /// Delete a task for good. + async fn delete_session(&self, session_id: String) -> Result<(), String>; + async fn compact_session(&self, session_id: String) -> Result<(), String>; + async fn session_subagents(&self, session_id: String) -> Result, String>; + async fn cancel_external_agent( + &self, + session_id: String, + agent_id: String, + ) -> Result<(), String>; + async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String>; + async fn set_session_web_enabled( + &self, + session_id: String, + enabled: bool, + ) -> Result; + async fn context_usage( + &self, + session_id: String, + model: Option, + ) -> Result, String>; + async fn read_image_attachment( + &self, + session_id: String, + attachment_id: String, + ) -> Result, String>; + + // ---- Messages and runs ------------------------------------------------- + + /// Returns the run id. + async fn send_message(&self, request: AgentSendMessageRequest) -> Result; + async fn cancel_run(&self, run_id: String) -> Result<(), String>; + async fn cancel_queued_message( + &self, + session_id: String, + queue_id: String, + ) -> Result; + async fn begin_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String>; + async fn end_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String>; + /// Returns false when no question was pending. + async fn answer_question(&self, request_id: String, answer: String) -> Result; + async fn permission_respond( + &self, + session_id: String, + request_id: String, + allow: bool, + ) -> Result<(), String>; + async fn ask_side_question( + &self, + session_id: String, + request_id: String, + prior: Vec, + question: String, + ) -> Result<(), String>; + + // ---- Summaries --------------------------------------------------------- + + async fn summarize_tool_call( + &self, + session_id: String, + tool_name: String, + input: Option, + output_text: String, + ) -> Result, String>; + async fn summarize_thinking( + &self, + session_id: String, + thinking_text: String, + ) -> Result, String>; + /// Stored summaries for one session, keyed by timeline item id. + async fn tool_summaries(&self, session_id: String) -> Result, String>; + async fn store_tool_summary( + &self, + session_id: String, + item_id: String, + summary: String, + ) -> Result<(), String>; + + // ---- Models and skills ------------------------------------------------- + + async fn available_model_ids(&self) -> Result, String>; + async fn model_supports_vision(&self, model: String) -> Result, String>; + async fn list_slash_commands( + &self, + working_dir: Option, + ) -> Result, String>; + async fn resolve_slash_command( + &self, + working_dir: Option, + command: String, + args: String, + ) -> Result, String>; + + // ---- Integrations and MCP ---------------------------------------------- + + async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String>; + async fn set_session_mcp_server_enabled( + &self, + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + ) -> Result, String>; + async fn list_mcp_servers(&self) -> Result, String>; + async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String>; + async fn list_integrations(&self) -> Result, String>; + async fn set_integration_enabled( + &self, + id: String, + enabled: bool, + ) -> Result, String>; + /// Persist a curated integration after its permission flow ran on the + /// host. The flow itself is a local capability the client starts from + /// its own UI thread; a remote client cannot run it. + async fn setup_integration(&self, id: String) -> Result, String>; + + // ---- Host configuration ------------------------------------------------ + + async fn session_defaults(&self) -> Result; + async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String>; + async fn save_default_model(&self, model: String) -> Result<(), String>; + async fn usage_summary(&self) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hub_delivers_to_every_live_subscriber_and_prunes_dropped_ones() { + let hub = HostEventHub::default(); + let mut first = hub.subscribe(); + let second = hub.subscribe(); + drop(second); + hub.publish(HostEvent::ProjectBranch { + project_root: "/p".to_string(), + branch: Some("main".to_string()), + }); + assert!(matches!( + first.try_recv(), + Ok(HostEvent::ProjectBranch { branch: Some(branch), .. }) if branch == "main" + )); + assert_eq!(hub.subscriber_count(), 1); + } + + #[test] + fn host_ids_round_trip_and_know_local() { + assert!(HostId::local().is_local()); + assert!(!HostId::new("abc").is_local()); + let json = serde_json::to_string(&HostId::new("abc")).unwrap(); + assert_eq!(json, "\"abc\""); + assert_eq!( + serde_json::from_str::(&json).unwrap().as_str(), + "abc" + ); + } + + #[test] + fn blank_harness_instructions_mean_the_default() { + assert_eq!( + effective_harness_instructions(" \n"), + DEFAULT_HARNESS_INSTRUCTIONS + ); + assert_eq!(effective_harness_instructions(" custom "), "custom"); + let defaults = HostSessionDefaults::default(); + assert_eq!(defaults.permission_mode, PERMISSION_MODE_SMART_APPROVE); + assert!(defaults.web_enabled); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/lib.rs b/apps/maple-agent/crates/maple-agent/src/lib.rs index fe32d602a..a19e4e552 100644 --- a/apps/maple-agent/crates/maple-agent/src/lib.rs +++ b/apps/maple-agent/crates/maple-agent/src/lib.rs @@ -11,6 +11,7 @@ pub mod acp; pub mod agent; mod desktop_environment; +pub mod host; pub use desktop_environment::prepare_process_environment; pub mod maple_api; pub mod open_secret_config; diff --git a/apps/maple-agent/crates/maple-remote/Cargo.toml b/apps/maple-agent/crates/maple-remote/Cargo.toml new file mode 100644 index 000000000..583b29191 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "maple-remote" +description = "Wire protocol between a Maple client and a host: framing, JSON-RPC over channel 0, the host server, and the remote HostBackend" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +[dependencies] +maple-agent = { path = "../maple-agent", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } +tokio = { workspace = true } +async-trait = { workspace = true } +futures-util = { workspace = true } +log = { workspace = true } +bytes = "1" +uuid = { version = "1", features = ["v4"] } +tokio-util = { workspace = true } +# Noise: pairing runs XXpsk3 with the one-time code as the pre-shared key, +# later connections run IK with pinned statics. The default resolver builds +# on the dalek and chacha crates the Rust SDK already brings in. +snow = "0.10" +# Plain `ws://` on direct connections; Noise inside is the encryption. TLS +# features stay off; the relay adds `wss://` to the enclave later. +tokio-tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } +sha2 = "0.10" +rand = "0.8" +base64 = "0.22" + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/apps/maple-agent/crates/maple-remote/src/carrier.rs b/apps/maple-agent/crates/maple-remote/src/carrier.rs new file mode 100644 index 000000000..c31dd5aff --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/carrier.rs @@ -0,0 +1,95 @@ +//! A bidirectional frame transport. +//! +//! The server and the client are written against this trait. Tests and +//! the local loopback use [`in_process_pair`]; the WebSocket carrier with +//! Noise inside is a separate implementation that delivers the same +//! frames. + +use async_trait::async_trait; +use tokio::sync::mpsc; + +use crate::frame::Frame; + +/// The sending half of a carrier. +#[async_trait] +pub trait FrameSink: Send + 'static { + /// Deliver one frame. `Err` means the carrier is gone. + async fn send(&mut self, frame: Frame) -> Result<(), String>; + /// Close the carrier for good. + async fn close(&mut self); +} + +/// The receiving half of a carrier. +#[async_trait] +pub trait FrameStream: Send + 'static { + /// The next frame, or `None` once the peer closed. + async fn recv(&mut self) -> Option; +} + +/// Both halves, before they are split. +pub struct Carrier { + pub sink: Box, + pub stream: Box, +} + +struct ChannelSink(Option>); + +#[async_trait] +impl FrameSink for ChannelSink { + async fn send(&mut self, frame: Frame) -> Result<(), String> { + match &self.0 { + Some(tx) => tx + .send(frame) + .await + .map_err(|_| "peer closed the carrier".to_string()), + None => Err("carrier closed".to_string()), + } + } + + async fn close(&mut self) { + self.0 = None; + } +} + +struct ChannelStream(mpsc::Receiver); + +#[async_trait] +impl FrameStream for ChannelStream { + async fn recv(&mut self) -> Option { + self.0.recv().await + } +} + +/// Two connected carriers in one process. Frames sent on one arrive on +/// the other. `buffer` frames may be in flight each way. +pub fn in_process_pair(buffer: usize) -> (Carrier, Carrier) { + let (a_to_b_tx, a_to_b_rx) = mpsc::channel(buffer); + let (b_to_a_tx, b_to_a_rx) = mpsc::channel(buffer); + ( + Carrier { + sink: Box::new(ChannelSink(Some(a_to_b_tx))), + stream: Box::new(ChannelStream(b_to_a_rx)), + }, + Carrier { + sink: Box::new(ChannelSink(Some(b_to_a_tx))), + stream: Box::new(ChannelStream(a_to_b_rx)), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn frames_cross_the_pair_both_ways_and_close_ends_the_stream() { + let (mut a, mut b) = in_process_pair(4); + a.sink.send(Frame::control("to b")).await.unwrap(); + b.sink.send(Frame::control("to a")).await.unwrap(); + assert_eq!(b.stream.recv().await.unwrap().payload, "to b"); + assert_eq!(a.stream.recv().await.unwrap().payload, "to a"); + a.sink.close().await; + assert!(b.stream.recv().await.is_none()); + assert!(a.sink.send(Frame::control("late")).await.is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs new file mode 100644 index 000000000..4f12056fe --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -0,0 +1,1076 @@ +//! The client side: a [`HostBackend`] over one connection to a host. +//! +//! [`RemoteHostBackend`] speaks the wire to a [`crate::server::HostServer`] +//! and presents the same trait the local host does, so the UI drives it +//! without knowing where it runs. One instance is one connection; when the +//! connection ends the instance is dead, and whoever owns it reconnects +//! with a fresh one and treats the change as a resync. +//! +//! Delivery: every event carries the connection's sequence. A gap means +//! the host dropped this client's queue or something in between lost +//! frames; the backend then publishes [`HostEvent::Resync`] so the UI +//! re-reads what it shows, rather than trusting the stream. Liveness is +//! an application ping on its own budget; a request timeout is an +//! operation failure, never proof the connection is dead. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentImageUpload, AgentIntegration, + AgentMcpServer, AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, AgentTaskState, RecentProjectRoot, SideQuestionTurn, +}; +use maple_agent::host::{ + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, UsageSummary, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use tokio::sync::{Mutex, mpsc, oneshot, watch}; + +use crate::carrier::Carrier; +use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; +use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; +use crate::rpc::{self, Message, Request as RpcRequest, RpcError}; +use crate::streams::{ + ATTACHMENT_PURPOSE, MAX_IMAGE_BYTES, Opener, StreamOpen, StreamReceivers, StreamResult, + StreamSenders, UPLOAD_PURPOSE, close_frame, decode_credit, +}; +use crate::wire::{ + AttachmentHandle, BootstrapSnapshot, ClientHello, EVENT_METHOD, EventEnvelope, HostHello, + HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, RunRequest, + SessionRequest, SessionSnapshot, TimelinePage, UPLOAD_STREAMS_FEATURE, UploadRef, has_feature, +}; + +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// The handshake must complete within this. + pub connect_timeout: Duration, + /// Default budget for one request. + pub request_timeout: Duration, + /// Budget for a runtime start, which has its own long timeout on the + /// host. + pub long_request_timeout: Duration, + /// Application ping period. + pub ping_interval: Duration, + /// A ping unanswered for this long counts as a miss. + pub ping_timeout: Duration, + /// Consecutive misses before the connection is declared dead. + pub ping_misses: u32, + /// Timeline items requested per page. + pub timeline_page_items: usize, + pub max_outbound_bytes: usize, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(15), + request_timeout: Duration::from_secs(60), + long_request_timeout: Duration::from_secs(90), + ping_interval: Duration::from_secs(10), + ping_timeout: Duration::from_secs(15), + ping_misses: 2, + timeline_page_items: 200, + max_outbound_bytes: DEFAULT_MAX_OUTBOUND_BYTES, + } + } +} + +type Pending = oneshot::Sender>; + +/// Why a remote client cannot set up a curated integration. +const SETUP_IS_LOCAL: &str = "set up integrations on the host itself"; + +/// Most timeline items reserved up front on the host's announced length. +const MAX_PREALLOCATED_ITEMS: usize = 4096; + +/// Attachment streams the host opens to answer `session.read_attachment`, +/// paired with the request that asked. A request registers its waiter +/// before it goes out; a stream for a request nobody waits on is refused. +struct AttachmentReads { + receivers: StreamReceivers, + waiters: std::sync::Mutex>>, +} + +impl Default for AttachmentReads { + fn default() -> Self { + Self { + receivers: StreamReceivers::new(MAX_IMAGE_BYTES), + waiters: std::sync::Mutex::new(HashMap::new()), + } + } +} + +impl AttachmentReads { + /// Wait for the stream that answers `request_id`. + fn expect(&self, request_id: u64) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(request_id, tx); + rx + } + + fn resolve(&self, request_id: u64, result: StreamResult) { + let waiter = self + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request_id); + if let Some(waiter) = waiter { + let _ = waiter.send(result); + } + } + + /// The request failed or gave up waiting: forget its waiter and any + /// stream already opened for it, so late frames are dropped. + fn abandon(&self, request_id: u64) { + self.waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request_id); + self.receivers.abandon(&request_id); + } + + /// A frame on a host-opened channel. Returns the frame to send back: + /// credit, or a `Close` refusing a stream the host should stop. + fn on_frame(&self, frame: &Frame) -> Option { + match frame.kind { + FrameKind::Open => { + let open: StreamOpen = match serde_json::from_slice(&frame.payload) { + Ok(open) => open, + Err(error) => { + return Some(close_frame(frame.channel, Some(&error.to_string()))); + } + }; + let expected = open.request_id.filter(|request_id| { + self.waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(request_id) + }); + let Some(request_id) = expected.filter(|_| open.purpose == ATTACHMENT_PURPOSE) + else { + return Some(close_frame( + frame.channel, + Some("no request awaits this stream"), + )); + }; + match self.receivers.accept(frame.channel, request_id, open.len) { + Ok(()) => None, + Err(reason) => { + self.resolve(request_id, Err(reason.clone())); + Some(close_frame(frame.channel, Some(&reason))) + } + } + } + FrameKind::Data => match self.receivers.on_data(frame.channel, &frame.payload) { + Ok(credit) => credit, + Err((request_id, reason)) => { + self.resolve(request_id, Err(reason.clone())); + Some(close_frame(frame.channel, Some(&reason))) + } + }, + FrameKind::Close => { + if let Some((request_id, result)) = + self.receivers.on_close(frame.channel, &frame.payload) + { + self.resolve(request_id, result); + } + None + } + // The host does not grant credit on its own stream. + FrameKind::Credit => None, + } + } + + /// The connection ended: every waiter fails. + fn fail_all(&self, reason: &str) { + self.receivers.clear(); + let waiters = std::mem::take( + &mut *self + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, waiter) in waiters { + let _ = waiter.send(Err(reason.to_string())); + } + } +} + +pub struct RemoteHostBackend { + id: HostId, + hello: HostHello, + config: ClientConfig, + out: Outbound, + pending: Mutex>, + next_id: AtomicU64, + events: Arc, + /// Streams this client opens: uploads ahead of `run.send`. + senders: StreamSenders, + /// Streams the host opens: attachment reads. + attachments: AttachmentReads, + closed: watch::Sender>, + tasks: std::sync::Mutex>>, +} + +impl RemoteHostBackend { + /// Open a connection: run the handshake and start the reader and the + /// keepalive. Fails when the host refuses the hello. + pub async fn connect( + carrier: Carrier, + hello: ClientHello, + config: ClientConfig, + ) -> Result, String> { + let Carrier { + mut sink, + mut stream, + } = carrier; + let (out, mut queue) = outbound::channel(config.max_outbound_bytes); + let (closed_tx, _) = watch::channel(None); + let (inbound_tx, mut inbound_rx) = mpsc::unbounded_channel::>(); + // The reader task only moves frames; demultiplexing needs the + // backend, which does not exist until the handshake answered. + let reader = tokio::spawn(async move { + loop { + let frame = stream.recv().await; + let ended = frame.is_none(); + if inbound_tx.send(frame).is_err() || ended { + break; + } + } + }); + let writer = tokio::spawn(async move { + while let Some(frame) = queue.recv().await { + if sink.send(frame).await.is_err() { + break; + } + } + sink.close().await; + }); + + // Handshake, by hand: the demultiplexer is not running yet. + let hello_request = RpcRequest::new( + 1, + "host.hello", + serde_json::to_value(&hello).map_err(|error| error.to_string())?, + ); + out.try_send(Frame::control(rpc::encode(&Message::Request( + hello_request, + ))?))?; + let answer = tokio::time::timeout(config.connect_timeout, async { + loop { + match inbound_rx.recv().await.flatten() { + Some(frame) if frame.channel == CONTROL_CHANNEL => { + if let Message::Response(response) = rpc::decode(&frame.payload)? { + return Ok::<_, String>(response); + } + } + Some(_) => continue, + None => return Err("the host closed the connection".to_string()), + } + } + }) + .await + .map_err(|_| "the host did not answer the handshake in time".to_string())??; + let host_hello: HostHello = match (answer.result, answer.error) { + (Some(value), _) => serde_json::from_value(value).map_err(|error| error.to_string())?, + (None, Some(error)) => return Err(error.message), + (None, None) => return Err("empty handshake answer".to_string()), + }; + if host_hello.protocol != PROTOCOL_VERSION { + return Err(format!( + "the host speaks protocol {}; this client speaks {PROTOCOL_VERSION}", + host_hello.protocol + )); + } + + let this = Arc::new(Self { + id: HostId::new(host_hello.host.id.clone()), + hello: host_hello, + config, + out, + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(2), + events: Arc::new(HostEventHub::default()), + senders: StreamSenders::new(Opener::Client), + attachments: AttachmentReads::default(), + closed: closed_tx, + tasks: std::sync::Mutex::new(vec![reader, writer]), + }); + let demux = { + let this = Arc::clone(&this); + tokio::spawn(async move { + let mut expected_seq: u64 = this.hello.seq + 1; + while let Some(Some(frame)) = inbound_rx.recv().await { + this.on_frame(frame, &mut expected_seq).await; + } + this.mark_closed("the host closed the connection").await; + }) + }; + let pinger = { + let this = Arc::clone(&this); + tokio::spawn(async move { this.keepalive().await }) + }; + this.tasks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend([demux, pinger]); + Ok(this) + } + + /// What the host said about itself. + pub fn host_hello(&self) -> &HostHello { + &self.hello + } + + /// The host's package version and, when its build knew it, the git + /// revision it was built from. + pub fn host_version(&self) -> (String, Option) { + (self.hello.app_version.clone(), self.hello.build.clone()) + } + + /// Resolves with the reason once the connection is gone. + pub fn closed(&self) -> watch::Receiver> { + self.closed.subscribe() + } + + pub fn is_closed(&self) -> bool { + self.closed.borrow().is_some() + } + + /// End the connection now. + pub async fn close(&self) { + self.mark_closed("closed by the client").await; + } + + async fn mark_closed(&self, reason: &str) { + if self.closed.borrow().is_some() { + return; + } + self.closed.send_replace(Some(reason.to_string())); + for (_, pending) in self.pending.lock().await.drain() { + let _ = pending.send(Err(RpcError::host(reason))); + } + self.senders.fail_all(reason); + self.attachments.fail_all(reason); + let tasks = std::mem::take( + &mut *self + .tasks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for task in tasks { + task.abort(); + } + } + + async fn on_frame(&self, frame: Frame, expected_seq: &mut u64) { + if frame.channel != CONTROL_CHANNEL { + match Opener::of_channel(frame.channel) { + // A stream this client is sending on: the host's flow + // control and its acknowledgement or refusal. + Opener::Client => match frame.kind { + FrameKind::Credit => { + if let Some(credit) = decode_credit(&frame.payload) { + self.senders.credit(frame.channel, credit); + } + } + FrameKind::Close => self.senders.on_close(frame.channel, &frame.payload), + FrameKind::Open | FrameKind::Data => {} + }, + Opener::Host => { + if let Some(reply) = self.attachments.on_frame(&frame) + && let Err(error) = self.out.try_send(reply) + { + self.mark_closed(&error).await; + } + } + } + return; + } + let message = match rpc::decode(&frame.payload) { + Ok(message) => message, + Err(error) => { + log::debug!("dropping undecodable control frame: {error}"); + return; + } + }; + match message { + Message::Response(response) => { + if let Some(pending) = self.pending.lock().await.remove(&response.id) { + let _ = pending.send(match (response.result, response.error) { + (Some(value), _) => Ok(value), + (None, Some(error)) => Err(error), + (None, None) => Ok(Value::Null), + }); + } + } + Message::Notification(notification) if notification.method == EVENT_METHOD => { + match serde_json::from_value::(notification.params) { + Ok(envelope) => { + if envelope.seq != *expected_seq { + log::warn!( + "host event sequence jumped from {} to {}; resyncing", + *expected_seq, + envelope.seq + ); + self.events.publish(HostEvent::Resync); + } + *expected_seq = envelope.seq + 1; + self.events.publish(envelope.event); + } + Err(error) => log::debug!("dropping undecodable event: {error}"), + } + } + Message::Notification(notification) => { + log::debug!("ignoring notification {}", notification.method); + } + Message::Request(request) => { + log::debug!("ignoring request {} from the host", request.method); + } + } + } + + async fn keepalive(&self) { + let mut misses = 0; + loop { + tokio::time::sleep(self.config.ping_interval).await; + if self.is_closed() { + return; + } + let ping = self.call_with_timeout::( + &HostRequest::Ping, + self.config.ping_timeout, + ); + match ping.await { + Ok(_) => misses = 0, + Err(error) => { + misses += 1; + log::debug!("ping missed ({misses}): {error}"); + if misses >= self.config.ping_misses { + self.mark_closed("the host stopped answering").await; + return; + } + } + } + } + } + + async fn call(&self, request: &T) -> Result { + self.call_with_timeout(request, self.config.request_timeout) + .await + } + + async fn call_with_timeout( + &self, + request: &T, + timeout: Duration, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let value = self.call_with_id(id, request, timeout).await?; + serde_json::from_value(value).map_err(|error| format!("bad answer from the host: {error}")) + } + + /// Send a request under a caller-chosen id and wait for its answer. + /// The caller picks the id when it must pair a stream with it. + async fn call_with_id( + &self, + id: u64, + request: &T, + timeout: Duration, + ) -> Result { + if let Some(reason) = self.closed.borrow().clone() { + return Err(reason); + } + let (method, params) = crate::wire::encode_request(request)?; + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + let message = Message::Request(RpcRequest::new(id, method.clone(), params)); + if let Err(error) = + rpc::encode(&message).and_then(|bytes| self.out.try_send(Frame::control(bytes))) + { + self.pending.lock().await.remove(&id); + return Err(error); + } + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(Ok(value))) => Ok(value), + Ok(Ok(Err(error))) => Err(error.message), + Ok(Err(_)) => Err("the connection ended".to_string()), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(format!("{method} timed out")) + } + } + } + + /// Stream each image to the host ahead of `run.send`, one after the + /// other, and return what the request names them by. A failed upload + /// fails the whole send. + async fn upload_attachments( + &self, + attachments: Vec, + ) -> Result, String> { + if attachments.is_empty() { + return Ok(Vec::new()); + } + if !has_feature(&self.hello.features, UPLOAD_STREAMS_FEATURE) { + return Err("this host cannot receive image attachments; update the host".to_string()); + } + let mut uploads = Vec::with_capacity(attachments.len()); + for AgentImageUpload { name, data_url } in attachments { + // Decode once and let the data URL go, so only the bytes + // stay in memory while they stream. + let (mime, bytes) = decode_data_url(&data_url)?; + drop(data_url); + if bytes.len() > MAX_IMAGE_BYTES { + return Err(format!("{name} is too large (max 10MB)")); + } + let upload_id = uuid::Uuid::new_v4().to_string(); + let mut sender = self.senders.open( + &self.out, + StreamOpen { + purpose: UPLOAD_PURPOSE.to_string(), + request_id: None, + upload_id: Some(upload_id.clone()), + mime: Some(mime), + len: Some(bytes.len() as u64), + }, + )?; + let bytes = &bytes; + let transfer = async move { + sender.send_all(bytes).await?; + sender.wait_for_ack().await + }; + tokio::time::timeout(self.config.long_request_timeout, transfer) + .await + .map_err(|_| format!("uploading {name} timed out"))? + .map_err(|error| format!("uploading {name} failed: {error}"))?; + uploads.push(UploadRef { upload_id, name }); + } + Ok(uploads) + } + + /// Fetch every page of a snapshot's timeline. `expected_len` is the + /// host's word and only sizes the first allocation, within reason. + async fn page_timeline( + &self, + session_id: &str, + expected_len: usize, + ) -> Result, String> { + let mut items = Vec::with_capacity(expected_len.min(MAX_PREALLOCATED_ITEMS)); + loop { + let page: TimelinePage = self + .call(&SessionRequest::Timeline { + session_id: session_id.to_string(), + offset: items.len(), + limit: self.config.timeline_page_items, + }) + .await?; + let received = page.items.len(); + items.extend(page.items); + if !page.has_more || received == 0 { + break; + } + } + Ok(items) + } +} + +#[async_trait] +impl HostBackend for RemoteHostBackend { + fn id(&self) -> &HostId { + &self.id + } + + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + + async fn bootstrap(&self) -> Result { + let snapshot: BootstrapSnapshot = self.call(&HostRequest::Bootstrap).await?; + let mut bootstrap = snapshot.bootstrap; + if let Some(latest) = bootstrap.latest.as_mut() { + latest.timeline = self + .page_timeline(&latest.session.id, snapshot.latest_timeline_len) + .await?; + } + Ok(bootstrap) + } + + async fn start_runtime( + &self, + request: Option, + ) -> Result { + self.call_with_timeout( + &HostRequest::StartRuntime { request }, + self.config.long_request_timeout, + ) + .await + } + + async fn stop_runtime(&self) -> Result { + self.call(&HostRequest::StopRuntime).await + } + + async fn recent_project_roots(&self) -> Result, String> { + self.call(&ProjectRequest::RecentRoots).await + } + + async fn select_project_root( + &self, + path: String, + ) -> Result { + self.call(&ProjectRequest::SelectRoot { path }).await + } + + async fn remove_project_root( + &self, + path: String, + fallback: Option, + ) -> Result<(), String> { + self.call(&ProjectRequest::RemoveRoot { path, fallback }) + .await + } + + async fn suggest_directories(&self, query: String) -> Result, String> { + self.call(&ProjectRequest::SuggestDirectories { query }) + .await + } + + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.call(&ProjectRequest::Watch { path }).await + } + + async fn unwatch_project_root(&self, path: String) -> Result<(), String> { + self.call(&ProjectRequest::Unwatch { path }).await + } + + async fn project_trust(&self, path: String) -> Result { + self.call(&ProjectRequest::Trust { path }).await + } + + async fn set_project_trust( + &self, + path: String, + trusted: bool, + ) -> Result { + self.call(&ProjectRequest::SetTrust { path, trusted }).await + } + + async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String> { + self.call(&SessionRequest::List { project_root }).await + } + + async fn create_session( + &self, + request: Option, + ) -> Result { + self.call(&SessionRequest::Create { request }).await + } + + async fn load_session(&self, session_id: String) -> Result { + let snapshot: SessionSnapshot = self + .call(&SessionRequest::Load { + session_id: session_id.clone(), + }) + .await?; + let mut detail = snapshot.detail; + detail.timeline = self + .page_timeline(&session_id, snapshot.timeline_len) + .await?; + Ok(detail) + } + + async fn rename_session( + &self, + session_id: String, + title: String, + ) -> Result { + self.call(&SessionRequest::Rename { session_id, title }) + .await + } + + async fn set_session_state( + &self, + session_id: String, + state: AgentTaskState, + ) -> Result { + self.call(&SessionRequest::SetState { session_id, state }) + .await + } + + async fn delete_session(&self, session_id: String) -> Result<(), String> { + self.call(&SessionRequest::Delete { session_id }).await + } + + async fn compact_session(&self, session_id: String) -> Result<(), String> { + self.call(&SessionRequest::Compact { session_id }).await + } + + async fn session_subagents(&self, session_id: String) -> Result, String> { + self.call(&SessionRequest::Subagents { session_id }).await + } + + async fn cancel_external_agent( + &self, + session_id: String, + agent_id: String, + ) -> Result<(), String> { + self.call(&SessionRequest::CancelExternalAgent { + session_id, + agent_id, + }) + .await + } + + async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String> { + self.call(&SessionRequest::SetPermissionMode { session_id, mode }) + .await + } + + async fn set_session_web_enabled( + &self, + session_id: String, + enabled: bool, + ) -> Result { + self.call(&SessionRequest::SetWebEnabled { + session_id, + enabled, + }) + .await + } + + async fn context_usage( + &self, + session_id: String, + model: Option, + ) -> Result, String> { + self.call(&HostRequest::ContextUsage { session_id, model }) + .await + } + + async fn read_image_attachment( + &self, + session_id: String, + attachment_id: String, + ) -> Result, String> { + let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let request = SessionRequest::ReadAttachment { + session_id, + attachment_id, + }; + // The open frame precedes the answer on the same ordered carrier, + // so the waiter must exist before the request goes out. + let receiver = self.attachments.expect(request_id); + // The host may have opened the stream before its answer failed or + // the wait ran out; whatever it opened for this request goes too. + let value = match self + .call_with_id(request_id, &request, self.config.request_timeout) + .await + { + Ok(value) => value, + Err(error) => { + self.attachments.abandon(request_id); + return Err(error); + } + }; + let handle: AttachmentHandle = + serde_json::from_value(value).map_err(|error| error.to_string())?; + match tokio::time::timeout(self.config.request_timeout, receiver).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("attachment transfer was cut off".to_string()), + Err(_) => { + self.attachments.abandon(request_id); + Err(format!( + "attachment transfer on stream {} timed out", + handle.stream + )) + } + } + } + + async fn send_message(&self, mut request: AgentSendMessageRequest) -> Result { + let attachments = std::mem::take(&mut request.attachments); + let uploads = self.upload_attachments(attachments).await?; + self.call(&RunRequest::Send { request, uploads }).await + } + + async fn cancel_run(&self, run_id: String) -> Result<(), String> { + self.call(&RunRequest::Cancel { run_id }).await + } + + async fn cancel_queued_message( + &self, + session_id: String, + queue_id: String, + ) -> Result { + self.call(&RunRequest::CancelQueued { + session_id, + queue_id, + }) + .await + } + + async fn begin_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.call(&RunRequest::BeginQueuedEdit { + session_id, + queue_id, + }) + .await + } + + async fn end_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.call(&RunRequest::EndQueuedEdit { + session_id, + queue_id, + }) + .await + } + + async fn answer_question(&self, request_id: String, answer: String) -> Result { + self.call(&RunRequest::AnswerQuestion { request_id, answer }) + .await + } + + async fn permission_respond( + &self, + session_id: String, + request_id: String, + allow: bool, + ) -> Result<(), String> { + self.call(&RunRequest::PermissionRespond { + session_id, + request_id, + allow, + }) + .await + } + + async fn ask_side_question( + &self, + session_id: String, + request_id: String, + prior: Vec, + question: String, + ) -> Result<(), String> { + self.call(&RunRequest::AskSideQuestion { + session_id, + request_id, + prior, + question, + }) + .await + } + + async fn summarize_tool_call( + &self, + session_id: String, + tool_name: String, + input: Option, + output_text: String, + ) -> Result, String> { + self.call(&RunRequest::SummarizeToolCall { + session_id, + tool_name, + input, + output_text, + }) + .await + } + + async fn summarize_thinking( + &self, + session_id: String, + thinking_text: String, + ) -> Result, String> { + self.call(&RunRequest::SummarizeThinking { + session_id, + thinking_text, + }) + .await + } + + async fn tool_summaries(&self, session_id: String) -> Result, String> { + self.call(&HostRequest::ToolSummaries { session_id }).await + } + + async fn store_tool_summary( + &self, + session_id: String, + item_id: String, + summary: String, + ) -> Result<(), String> { + self.call(&HostRequest::StoreToolSummary { + session_id, + item_id, + summary, + }) + .await + } + + async fn available_model_ids(&self) -> Result, String> { + self.call(&ModelRequest::List).await + } + + async fn model_supports_vision(&self, model: String) -> Result, String> { + self.call(&ModelRequest::SupportsVision { model }).await + } + + async fn list_slash_commands( + &self, + working_dir: Option, + ) -> Result, String> { + self.call(&ModelRequest::SlashCommands { working_dir }) + .await + } + + async fn resolve_slash_command( + &self, + working_dir: Option, + command: String, + args: String, + ) -> Result, String> { + self.call(&ModelRequest::ResolveSlashCommand { + working_dir, + command, + args, + }) + .await + } + + async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String> { + self.call(&IntegrationRequest::ListSessionMcp { session_id }) + .await + } + + async fn set_session_mcp_server_enabled( + &self, + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + ) -> Result, String> { + self.call(&IntegrationRequest::SetSessionMcp { + session_id, + name, + kind, + enabled, + }) + .await + } + + async fn list_mcp_servers(&self) -> Result, String> { + self.call(&IntegrationRequest::ListMcp).await + } + + async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String> { + self.call(&IntegrationRequest::SaveMcp { servers }).await + } + + async fn list_integrations(&self) -> Result, String> { + self.call(&IntegrationRequest::List).await + } + + async fn set_integration_enabled( + &self, + id: String, + enabled: bool, + ) -> Result, String> { + self.call(&IntegrationRequest::SetEnabled { id, enabled }) + .await + } + + /// The permission flow behind a setup runs on the host's own screen; + /// there is no wire method for it, so answer here. + async fn setup_integration(&self, _id: String) -> Result, String> { + Err(SETUP_IS_LOCAL.to_string()) + } + + async fn session_defaults(&self) -> Result { + self.call(&HostRequest::SessionDefaults).await + } + + async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String> { + self.call(&HostRequest::SetSessionDefaults { defaults }) + .await + } + + async fn save_default_model(&self, model: String) -> Result<(), String> { + self.call(&HostRequest::SaveDefaultModel { model }).await + } + + async fn usage_summary(&self) -> Result { + self.call(&HostRequest::UsageSummary).await + } +} + +/// The media type and bytes of a `data:;base64,` URL, the +/// shape the composer builds and the runtime stores. +fn decode_data_url(data_url: &str) -> Result<(String, Vec), String> { + use base64::Engine as _; + let (header, data) = data_url + .split_once(',') + .ok_or_else(|| "Image attachment must be a base64 data URL".to_string())?; + let mime = header + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")) + .filter(|mime| !mime.is_empty()) + .ok_or_else(|| "Image attachment must be a base64 data URL".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|_| "Image attachment is not valid base64".to_string())?; + if bytes.is_empty() { + return Err("Image attachment cannot be empty".to_string()); + } + Ok((mime.to_string(), bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_urls_decode_to_their_type_and_bytes() { + let (mime, bytes) = decode_data_url("data:image/png;base64,AQID").unwrap(); + assert_eq!(mime, "image/png"); + assert_eq!(bytes, [1, 2, 3]); + for bad in [ + "AQID", + "http://x,AQID", + "data:image/png,AQID", + "data:;base64,AQID", + "data:image/png;base64,", + "data:image/png;base64,!!", + ] { + assert!(decode_data_url(bad).is_err(), "{bad}"); + } + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/devices.rs b/apps/maple-agent/crates/maple-remote/src/devices.rs new file mode 100644 index 000000000..fd69a9fcb --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/devices.rs @@ -0,0 +1,258 @@ +//! The devices a host has paired with. +//! +//! One JSON file at mode 0600. A device is its static public key; the name +//! and account are what the device claimed in its last handshake, kept for +//! display. Revoking a device removes it here; the host's listener notices +//! and drops the device's live connections. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use crate::now_ms; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairedDevice { + pub public_key: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + pub paired_at_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_ms: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct DeviceFile { + #[serde(default)] + devices: Vec, +} + +pub struct DeviceStore { + path: PathBuf, + lock: Mutex<()>, +} + +/// Longest device name kept. A client claims its own name; it is display +/// text, not identity. +pub const MAX_DEVICE_NAME_CHARS: usize = 64; + +/// A device name as the host keeps and shows it: control characters +/// removed, cut to [`MAX_DEVICE_NAME_CHARS`], and trimmed. +pub fn clean_device_name(name: &str) -> String { + name.chars() + .filter(|ch| !ch.is_control()) + .take(MAX_DEVICE_NAME_CHARS) + .collect::() + .trim() + .to_string() +} + +impl DeviceStore { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Mutex::new(()), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn read(&self) -> Result { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("{} is not a device file: {error}", self.path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(DeviceFile::default()), + Err(error) => Err(format!("cannot read {}: {error}", self.path.display())), + } + } + + fn write(&self, file: &DeviceFile) -> Result<(), String> { + maple_agent::private_file::write_private_json(&self.path, file) + .map_err(|error| format!("cannot write {}: {error}", self.path.display())) + } + + pub fn list(&self) -> Result, String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(self.read()?.devices) + } + + pub fn is_paired(&self, public_key: &str) -> bool { + self.list() + .map(|devices| devices.iter().any(|device| device.public_key == public_key)) + .unwrap_or(false) + } + + /// Record a device that just paired. Pairing again with the same key + /// keeps the record and refreshes its name. + pub fn insert(&self, public_key: &str, name: &str) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut file = self.read()?; + let now = now_ms(); + let name = clean_device_name(name); + let device = match file + .devices + .iter_mut() + .find(|device| device.public_key == public_key) + { + Some(existing) => { + existing.name = name; + existing.last_seen_ms = Some(now); + existing.clone() + } + None => { + let device = PairedDevice { + public_key: public_key.to_string(), + name, + user_id: None, + paired_at_ms: now, + last_seen_ms: Some(now), + }; + file.devices.push(device.clone()); + device + } + }; + self.write(&file)?; + Ok(device) + } + + /// A paired device connected: keep what it claims about itself. + pub fn touch(&self, public_key: &str, name: &str, user_id: Option<&str>) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut file = self.read()?; + let Some(device) = file + .devices + .iter_mut() + .find(|device| device.public_key == public_key) + else { + return Ok(()); + }; + let name = clean_device_name(name); + if !name.is_empty() { + device.name = name; + } + device.user_id = user_id.map(str::to_string); + device.last_seen_ms = Some(now_ms()); + self.write(&file) + } + + /// Remove a device by public key, or by name when no key matches. A + /// name that matches several devices is refused; use the key. + pub fn revoke(&self, key_or_name: &str) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut file = self.read()?; + if let Some(index) = file + .devices + .iter() + .position(|device| device.public_key == key_or_name) + { + let removed = file.devices.remove(index); + self.write(&file)?; + return Ok(removed); + } + let matches: Vec = file + .devices + .iter() + .enumerate() + .filter(|(_, device)| device.name == key_or_name) + .map(|(index, _)| index) + .collect(); + match matches.as_slice() { + [] => Err(format!("no paired device matches {key_or_name:?}")), + [index] => { + let removed = file.devices.remove(*index); + self.write(&file)?; + Ok(removed) + } + _ => Err(format!( + "{key_or_name:?} names several devices; revoke by public key" + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn devices_insert_touch_and_revoke() { + let dir = std::env::temp_dir().join(format!("maple-devices-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = DeviceStore::new(dir.join("devices.json")); + assert!(store.list().unwrap().is_empty()); + assert!(!store.is_paired("k1")); + store.insert("k1", "laptop").unwrap(); + store.insert("k2", "laptop").unwrap(); + assert!(store.is_paired("k1")); + store.touch("k1", "bens-laptop", Some("user-1")).unwrap(); + let listed = store.list().unwrap(); + assert_eq!(listed[0].name, "bens-laptop"); + assert_eq!(listed[0].user_id.as_deref(), Some("user-1")); + assert!( + store.revoke("laptop").is_ok(), + "only k2 is still named laptop" + ); + assert!(store.revoke("nobody").is_err()); + store.insert("k1", "laptop").unwrap(); + assert_eq!(store.revoke("k1").unwrap().public_key, "k1"); + assert!(store.list().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn revoke_prefers_the_key_and_names_are_kept_short_and_printable() { + let dir = std::env::temp_dir().join(format!("maple-devices-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = DeviceStore::new(dir.join("devices.json")); + store.insert("k1", "laptop").unwrap(); + // A device that names itself after another device's key. + store.insert("k2", "k1").unwrap(); + assert_eq!( + store.revoke("k1").unwrap().public_key, + "k1", + "the key match wins over the name match" + ); + assert_eq!( + store.revoke("k1").unwrap().public_key, + "k2", + "with no key left to match, the name is used" + ); + + let long = format!("a\u{0}b\tc\u{7f}{}", "x".repeat(100)); + store.insert("k3", &long).unwrap(); + let name = store.list().unwrap()[0].name.clone(); + assert_eq!(name.chars().count(), MAX_DEVICE_NAME_CHARS); + assert!(name.starts_with("abcxxx"), "{name}"); + assert!(name.chars().all(|ch| !ch.is_control())); + store.touch("k3", "\u{1b}[31m", None).unwrap(); + assert_eq!( + store.list().unwrap()[0].name, + "[31m", + "escape codes are stripped on touch" + ); + store.touch("k3", "\u{0}\u{1}", None).unwrap(); + assert_eq!( + store.list().unwrap()[0].name, + "[31m", + "a name that cleans to nothing keeps the old one" + ); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/dial.rs b/apps/maple-agent/crates/maple-remote/src/dial.rs new file mode 100644 index 000000000..c3d6b4654 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/dial.rs @@ -0,0 +1,66 @@ +//! The client role on the network: dialing a host over plain WebSocket +//! with Noise inside. +//! +//! [`connect_direct`] dials a host by address, either to pair with a code +//! or to reconnect with the host's pinned static key. The relay is another +//! connector later; everything above the carrier is shared. + +use crate::carrier::Carrier; +use crate::keys::{StaticKey, decode_key, encode_key}; +use crate::net::{HANDSHAKE_TIMEOUT, websocket_config}; +use crate::noise::{self, Initiate}; +use crate::pairing::PairingCode; + +/// What to dial for. +pub enum ConnectTarget { + /// First contact: pair with the code the host published. + Pair(PairingCode), + /// A host already paired, whose static key is pinned. + Host { host_key: String }, +} + +/// A carrier to the host at `address` plus the host's static key, which +/// the client pins after pairing and verifies afterwards. +pub struct Dialed { + pub carrier: Carrier, + pub host_key: String, +} + +/// Dial `address` (`host:port`) over plain WebSocket and run the Noise +/// handshake as `device`. +pub async fn connect_direct( + address: &str, + device: &StaticKey, + target: ConnectTarget, +) -> Result { + let url = format!("ws://{address}/"); + let (socket, _) = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + tokio_tungstenite::connect_async_with_config(&url, Some(websocket_config()), true), + ) + .await + .map_err(|_| format!("connecting to {address} timed out"))? + .map_err(|error| format!("cannot connect to {address}: {error}"))?; + let initiate = match &target { + ConnectTarget::Pair(code) => Initiate::Pair { psk: code.psk() }, + ConnectTarget::Host { host_key } => Initiate::Session { + host_static: decode_key(host_key)?, + }, + }; + let established = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + noise::initiate(socket, device.private(), initiate), + ) + .await + .map_err(|_| "the host did not finish the handshake in time".to_string())??; + let host_key = encode_key(&established.remote_static); + if let ConnectTarget::Host { host_key: pinned } = &target + && pinned != &host_key + { + return Err("the host's key does not match the pinned key".to_string()); + } + Ok(Dialed { + carrier: established.carrier, + host_key, + }) +} diff --git a/apps/maple-agent/crates/maple-remote/src/frame.rs b/apps/maple-agent/crates/maple-remote/src/frame.rs new file mode 100644 index 000000000..d7a01e2e2 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/frame.rs @@ -0,0 +1,140 @@ +//! Framing inside one encrypted message. +//! +//! Every message after the handshake is one frame: +//! `[channel: u16 BE][kind: u8][payload]`. Channel 0 is the control +//! channel and carries one JSON-RPC message per `Data` frame. Channels 1 +//! and up are binary streams opened by the side that sends the data: +//! clients open odd channels, hosts even ones. See [`crate::streams`]. + +use bytes::{BufMut, Bytes, BytesMut}; + +/// The control channel: JSON-RPC 2.0. +pub const CONTROL_CHANNEL: u16 = 0; + +/// Largest control frame either side accepts. Anything larger belongs on +/// a stream or must be paged. +pub const MAX_CONTROL_FRAME_BYTES: usize = 4 * 1024 * 1024; + +/// Largest data frame on a binary stream. +pub const MAX_STREAM_FRAME_BYTES: usize = 256 * 1024; + +/// Bytes in the `[channel][kind]` header before the payload. +pub const HEADER_BYTES: usize = 3; + +/// Largest payload a frame on `channel` may carry. +pub fn max_payload_bytes(channel: u16) -> usize { + if channel == CONTROL_CHANNEL { + MAX_CONTROL_FRAME_BYTES + } else { + MAX_STREAM_FRAME_BYTES + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FrameKind { + /// Opens a stream. Payload: a JSON [`crate::streams::StreamOpen`]. + Open = 0, + /// Bytes on a stream, or one JSON-RPC message on channel 0. + Data = 1, + /// Ends a stream. From the sender: the bytes are complete, or a JSON + /// [`crate::streams::StreamClose`] names why it stopped. From the + /// receiver: it took the bytes, or a `StreamClose` names why it + /// refused them. + Close = 2, + /// The receiver grants the sender more `Data` frames on a stream. + /// Payload: a u32 BE count. + Credit = 3, +} + +impl FrameKind { + fn from_byte(byte: u8) -> Option { + match byte { + 0 => Some(Self::Open), + 1 => Some(Self::Data), + 2 => Some(Self::Close), + 3 => Some(Self::Credit), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub channel: u16, + pub kind: FrameKind, + pub payload: Bytes, +} + +impl Frame { + pub fn control(payload: impl Into) -> Self { + Self { + channel: CONTROL_CHANNEL, + kind: FrameKind::Data, + payload: payload.into(), + } + } + + pub fn encode(&self) -> Bytes { + let mut out = BytesMut::with_capacity(HEADER_BYTES + self.payload.len()); + out.put_u16(self.channel); + out.put_u8(self.kind as u8); + out.extend_from_slice(&self.payload); + out.freeze() + } + + /// Decode one frame. Rejects a short header, an unknown kind, and a + /// payload over the limit for its channel. + pub fn decode(bytes: Bytes) -> Result { + if bytes.len() < HEADER_BYTES { + return Err("frame shorter than its header".to_string()); + } + let channel = u16::from_be_bytes([bytes[0], bytes[1]]); + let kind = FrameKind::from_byte(bytes[2]) + .ok_or_else(|| format!("unknown frame kind {}", bytes[2]))?; + let payload = bytes.slice(HEADER_BYTES..); + let limit = max_payload_bytes(channel); + if payload.len() > limit { + return Err(format!( + "frame of {} bytes on channel {channel} exceeds the {limit} byte limit", + payload.len() + )); + } + Ok(Self { + channel, + kind, + payload, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_round_trip() { + let frame = Frame { + channel: 7, + kind: FrameKind::Credit, + payload: Bytes::from_static(&[0, 0, 0, 16]), + }; + let decoded = Frame::decode(frame.encode()).unwrap(); + assert_eq!(decoded, frame); + let control = Frame::control(r#"{"jsonrpc":"2.0"}"#); + assert_eq!(control.channel, CONTROL_CHANNEL); + assert_eq!(Frame::decode(control.encode()).unwrap(), control); + } + + #[test] + fn malformed_frames_are_refused() { + assert!(Frame::decode(Bytes::from_static(&[0, 0])).is_err()); + assert!(Frame::decode(Bytes::from_static(&[0, 0, 9])).is_err()); + let oversized = Frame { + channel: 3, + kind: FrameKind::Data, + payload: Bytes::from(vec![0u8; MAX_STREAM_FRAME_BYTES + 1]), + }; + assert!(Frame::decode(oversized.encode()).is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/hosts.rs b/apps/maple-agent/crates/maple-remote/src/hosts.rs new file mode 100644 index 000000000..113181f65 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/hosts.rs @@ -0,0 +1,375 @@ +//! The hosts a client has paired with. +//! +//! One JSON file per account. A host is identified by its static public +//! key and may be reachable through several connections; adding a second +//! address to a host the client already knows merges into that host rather +//! than creating another. Loading salvages per entry: a malformed +//! connection is dropped, not the host, and a malformed host is dropped, +//! not the file. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use crate::now_ms; + +/// One way to reach a host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HostConnection { + /// Plain WebSocket to `host:port` on a LAN or a Tailscale network. + Direct { address: String }, +} + +impl HostConnection { + pub fn label(&self) -> &str { + match self { + Self::Direct { address } => address, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedHost { + /// The host's static public key. + pub id: String, + pub name: String, + pub connections: Vec, + pub paired_at_ms: u64, + /// The version the host announced at the last successful hello, so an + /// offline host still shows what it ran. Absent until it connected once + /// on a build that records it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_version: Option, + /// The build the host announced then, when its build knew it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_build: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct HostsFile { + #[serde(default)] + hosts: Vec, +} + +pub struct HostsStore { + path: PathBuf, + lock: Mutex<()>, +} + +/// Decode one saved host, dropping connections that do not parse. +fn salvage_host(value: serde_json::Value) -> Option { + let object = value.as_object()?; + let id = object.get("id")?.as_str()?.to_string(); + let name = object + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("host") + .to_string(); + let paired_at_ms = object + .get("pairedAtMs") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let connections = object + .get("connections") + .and_then(serde_json::Value::as_array) + .map(|list| { + list.iter() + .filter_map(|entry| serde_json::from_value(entry.clone()).ok()) + .collect() + }) + .unwrap_or_default(); + let string = |key: &str| { + object + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }; + Some(SavedHost { + id, + name, + connections, + paired_at_ms, + last_seen_version: string("lastSeenVersion"), + last_seen_build: string("lastSeenBuild"), + }) +} + +impl HostsStore { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Mutex::new(()), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn read(&self) -> Result, String> { + match std::fs::read(&self.path) { + Ok(bytes) => { + let file: HostsFile = serde_json::from_slice(&bytes).map_err(|error| { + format!("{} is not a hosts file: {error}", self.path.display()) + })?; + Ok(file.hosts.into_iter().filter_map(salvage_host).collect()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(format!("cannot read {}: {error}", self.path.display())), + } + } + + fn write(&self, hosts: &[SavedHost]) -> Result<(), String> { + let file = HostsFile { + hosts: hosts + .iter() + .map(|host| serde_json::to_value(host).unwrap_or(serde_json::Value::Null)) + .collect(), + }; + maple_agent::private_file::write_private_json(&self.path, &file) + .map_err(|error| format!("cannot write {}: {error}", self.path.display())) + } + + pub fn list(&self) -> Result, String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.read() + } + + pub fn get(&self, id: &str) -> Result, String> { + Ok(self.list()?.into_iter().find(|host| host.id == id)) + } + + /// Save a host. A host with the same key already saved keeps its + /// record and gains the new connections; the name changes only when + /// the saved one is empty, and the last seen version only when the + /// new record names one. + pub fn upsert(&self, host: SavedHost) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + let merged = match hosts.iter_mut().find(|saved| saved.id == host.id) { + Some(saved) => { + for connection in host.connections { + if !saved.connections.contains(&connection) { + saved.connections.push(connection); + } + } + if saved.name.trim().is_empty() { + saved.name = host.name; + } + if host.last_seen_version.is_some() { + saved.last_seen_version = host.last_seen_version; + saved.last_seen_build = host.last_seen_build; + } + saved.clone() + } + None => { + let mut host = host; + if host.paired_at_ms == 0 { + host.paired_at_ms = now_ms(); + } + hosts.push(host.clone()); + host + } + }; + self.write(&hosts)?; + Ok(merged) + } + + pub fn rename(&self, id: &str, name: &str) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + let host = hosts + .iter_mut() + .find(|host| host.id == id) + .ok_or_else(|| "no such host".to_string())?; + host.name = name.trim().to_string(); + self.write(&hosts) + } + + /// Record what a host announced at a successful hello. A host that is + /// no longer saved is ignored; an unchanged version is not rewritten. + pub fn record_last_seen( + &self, + id: &str, + version: &str, + build: Option<&str>, + ) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + let Some(host) = hosts.iter_mut().find(|host| host.id == id) else { + return Ok(()); + }; + if host.last_seen_version.as_deref() == Some(version) + && host.last_seen_build.as_deref() == build + { + return Ok(()); + } + host.last_seen_version = Some(version.to_string()); + host.last_seen_build = build.map(str::to_string); + self.write(&hosts) + } + + pub fn remove(&self, id: &str) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + hosts.retain(|host| host.id != id); + self.write(&hosts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts_merge_on_identity_and_salvage_bad_entries() { + let dir = std::env::temp_dir().join(format!("maple-hosts-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = HostsStore::new(dir.join("hosts.json")); + store + .upsert(SavedHost { + id: "k1".into(), + name: "workstation".into(), + connections: vec![HostConnection::Direct { + address: "100.64.0.7:7130".into(), + }], + paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, + }) + .unwrap(); + // Pairing again over another address merges into the same host. + let merged = store + .upsert(SavedHost { + id: "k1".into(), + name: "other name".into(), + connections: vec![ + HostConnection::Direct { + address: "192.168.1.20:7130".into(), + }, + HostConnection::Direct { + address: "100.64.0.7:7130".into(), + }, + ], + paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, + }) + .unwrap(); + assert_eq!(merged.name, "workstation"); + assert_eq!(merged.connections.len(), 2); + assert!(merged.paired_at_ms > 0); + assert_eq!(store.list().unwrap().len(), 1); + + store.rename("k1", " box ").unwrap(); + assert_eq!(store.get("k1").unwrap().unwrap().name, "box"); + + // A hand-edited file with one bad connection and one bad host. + std::fs::write( + store.path(), + r#"{"hosts":[ + {"id":"k1","name":"box","pairedAtMs":5,"connections":[ + {"kind":"direct","address":"a:1"}, + {"kind":"teleport","where":"nowhere"} + ]}, + {"name":"no id"}, + 7 + ]}"#, + ) + .unwrap(); + let hosts = store.list().unwrap(); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].connections.len(), 1); + + store.remove("k1").unwrap(); + assert!(store.list().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_last_seen_version_persists_and_a_file_without_one_still_loads() { + let dir = std::env::temp_dir().join(format!("maple-hosts-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = HostsStore::new(dir.join("hosts.json")); + // A file written before the version was recorded. + std::fs::write( + store.path(), + r#"{"hosts":[{"id":"k1","name":"box","pairedAtMs":5,"connections":[]}]}"#, + ) + .unwrap(); + let host = store.get("k1").unwrap().unwrap(); + assert_eq!(host.last_seen_version, None); + assert_eq!(host.last_seen_build, None); + assert!( + !std::fs::read_to_string(store.path()) + .unwrap() + .contains("lastSeen"), + "nothing is written until a hello is recorded" + ); + + // Recording an unknown host is not an error and writes nothing. + store.record_last_seen("k9", "0.1.0", None).unwrap(); + assert_eq!(store.list().unwrap().len(), 1); + + store + .record_last_seen("k1", "0.1.0", Some("63bcff5c")) + .unwrap(); + let host = store.get("k1").unwrap().unwrap(); + assert_eq!(host.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(host.last_seen_build.as_deref(), Some("63bcff5c")); + let text = std::fs::read_to_string(store.path()).unwrap(); + assert!(text.contains(r#""lastSeenVersion": "0.1.0""#), "{text}"); + assert!(text.contains(r#""lastSeenBuild": "63bcff5c""#), "{text}"); + + // A host that lost its build keeps the version and drops the build. + store.record_last_seen("k1", "0.2.0", None).unwrap(); + let host = store.get("k1").unwrap().unwrap(); + assert_eq!(host.last_seen_version.as_deref(), Some("0.2.0")); + assert_eq!(host.last_seen_build, None); + + // Pairing again does not erase what was seen unless the new record + // names a version. + let merged = store + .upsert(SavedHost { + id: "k1".into(), + name: String::new(), + connections: Vec::new(), + paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, + }) + .unwrap(); + assert_eq!(merged.last_seen_version.as_deref(), Some("0.2.0")); + let merged = store + .upsert(SavedHost { + id: "k1".into(), + name: String::new(), + connections: Vec::new(), + paired_at_ms: 0, + last_seen_version: Some("0.3.0".into()), + last_seen_build: Some("abc1234".into()), + }) + .unwrap(); + assert_eq!(merged.last_seen_version.as_deref(), Some("0.3.0")); + assert_eq!(merged.last_seen_build.as_deref(), Some("abc1234")); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/keys.rs b/apps/maple-agent/crates/maple-remote/src/keys.rs new file mode 100644 index 000000000..f7d69c87a --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/keys.rs @@ -0,0 +1,125 @@ +//! The static Noise key of a host or a device. +//! +//! One X25519 key pair per host and per device, generated on first use and +//! kept at mode 0600. The public key is the identity: a host is known to +//! its clients by it, and a device to its hosts. + +use std::path::Path; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD; + +#[derive(Clone)] +pub struct StaticKey { + private: [u8; 32], + public: [u8; 32], +} + +impl std::fmt::Debug for StaticKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticKey") + .field("public", &self.public_id()) + .finish_non_exhaustive() + } +} + +#[derive(Serialize, Deserialize)] +struct StoredKey { + private: String, + public: String, +} + +impl StaticKey { + pub fn generate() -> Result { + let keypair = snow::Builder::new( + crate::noise::SESSION_PATTERN + .parse() + .map_err(|error| format!("noise pattern: {error}"))?, + ) + .generate_keypair() + .map_err(|error| format!("cannot generate a key: {error}"))?; + Ok(Self { + private: to_array(&keypair.private)?, + public: to_array(&keypair.public)?, + }) + } + + /// The key at `path`, generated and saved there when there is none. + pub fn load_or_create(path: &Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => { + let stored: StoredKey = serde_json::from_slice(&bytes) + .map_err(|error| format!("{} is not a key file: {error}", path.display()))?; + Ok(Self { + private: decode_key(&stored.private)?, + public: decode_key(&stored.public)?, + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let key = Self::generate()?; + maple_agent::private_file::write_private_json( + path, + &StoredKey { + private: encode_key(&key.private), + public: encode_key(&key.public), + }, + ) + .map_err(|error| format!("cannot save {}: {error}", path.display()))?; + Ok(key) + } + Err(error) => Err(format!("cannot read {}: {error}", path.display())), + } + } + + pub fn private(&self) -> &[u8; 32] { + &self.private + } + + pub fn public(&self) -> &[u8; 32] { + &self.public + } + + /// The public key as the string identity used everywhere else. + pub fn public_id(&self) -> String { + encode_key(&self.public) + } +} + +pub fn encode_key(bytes: &[u8]) -> String { + ENGINE.encode(bytes) +} + +pub fn decode_key(text: &str) -> Result<[u8; 32], String> { + let bytes = ENGINE + .decode(text.trim()) + .map_err(|error| format!("not a key: {error}"))?; + to_array(&bytes) +} + +fn to_array(bytes: &[u8]) -> Result<[u8; 32], String> { + bytes + .try_into() + .map_err(|_| format!("a key is 32 bytes, not {}", bytes.len())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_persist_and_encode_round_trip() { + let dir = std::env::temp_dir().join(format!("maple-keys-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("key.json"); + let first = StaticKey::load_or_create(&path).unwrap(); + let again = StaticKey::load_or_create(&path).unwrap(); + assert_eq!(first.public(), again.public()); + assert_eq!(first.private(), again.private()); + assert_eq!(decode_key(&first.public_id()).unwrap(), *first.public()); + assert!(decode_key("short").is_err()); + assert_ne!(StaticKey::generate().unwrap().public(), first.public()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs new file mode 100644 index 000000000..19de1fccb --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -0,0 +1,62 @@ +//! The wire between a Maple client and a host. +//! +//! Layers, bottom up: +//! +//! - [`carrier`]: a bidirectional stream of [`frame::Frame`]s. The +//! in-process pair is for tests; [`noise`] delivers the same frames over +//! a WebSocket with Noise inside, [`listen`] accepts them for a host, +//! [`dial`] opens them for a client, and [`net`] holds what both share. +//! - [`keys`], [`pairing`], [`devices`]: the static key of a host or a +//! device, the one-time pairing code, and the host's paired device list. +//! - [`hosts`], [`manager`]: the client's saved hosts, and the connectors +//! that keep them connected and forward their events. +//! - [`frame`]: `[channel][kind][payload]`. Channel 0 is control and carries +//! JSON-RPC 2.0 ([`rpc`]). Other channels are binary streams with +//! credit-based flow control ([`streams`]); the host keeps the images a +//! client streams ahead of `run.send` in [`uploads`]. Every frame a side +//! sends goes through one byte-bounded queue ([`outbound`]); overflow +//! closes the connection rather than blocking the host. +//! - [`wire`]: the methods, grouped by domain, and the handshake. +//! - [`server`]: [`server::HostServer`] publishes any +//! [`maple_agent::host::HostBackend`] to connections. +//! - [`client`]: [`client::RemoteHostBackend`] implements `HostBackend` over +//! a connection, so the UI drives a remote host exactly like the local one. +//! +//! Compatibility rules for everything in [`wire`]: schemas are append-only; +//! new fields are optional with a serde default; unknown fields are +//! ignored; a field that stops being sent stays accepted. Every shim is +//! tagged `COMPAT(name): added in vX.Y, remove after YYYY-MM-DD`. Real +//! evolution goes through the feature bags in the handshake; the protocol +//! version is a tripwire that is bumped only for a change no feature flag +//! can express. + +pub mod carrier; +pub mod client; +pub mod devices; +pub mod dial; +pub mod frame; +pub mod hosts; +pub mod keys; +pub mod listen; +pub mod manager; +pub mod net; +pub mod noise; +pub mod outbound; +pub mod pairing; +pub mod rpc; +pub mod server; +pub mod streams; +pub mod uploads; +pub mod wire; + +pub use client::RemoteHostBackend; +pub use server::{HostServer, HostServerConfig}; +pub use wire::{ClientHello, HostInfo, PROTOCOL_VERSION}; + +/// Milliseconds since the Unix epoch, for the timestamps in the stores. +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} diff --git a/apps/maple-agent/crates/maple-remote/src/listen.rs b/apps/maple-agent/crates/maple-remote/src/listen.rs new file mode 100644 index 000000000..600516d86 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/listen.rs @@ -0,0 +1,162 @@ +//! The host role on the network: listening over plain WebSocket with +//! Noise inside. +//! +//! [`serve_listener`] accepts TCP connections on behalf of one +//! [`HostServer`], runs the handshake, registers a newly paired device, +//! and hands each established carrier to the server. Pairing failures are +//! counted per source address here; see [`PairingLimiter`]. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::sync::CancellationToken; + +use crate::devices::DeviceStore; +use crate::keys::{StaticKey, encode_key}; +use crate::net::{HANDSHAKE_TIMEOUT, websocket_config}; +use crate::noise::{self, HandshakeMode, Respond}; +use crate::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; +use crate::server::HostServer; + +/// The name a device carries until its first hello names it. +const UNNAMED_DEVICE: &str = "new device"; + +/// What a listening host needs besides the server: its key and its +/// device and pairing records. +pub struct HostStores { + pub key: StaticKey, + pub devices: Arc, + pub pending_pairing: Arc, + pub limiter: PairingLimiter, +} + +/// Accept connections until `shutdown` fires. +pub async fn serve_listener( + listener: TcpListener, + server: Arc, + stores: Arc, + shutdown: CancellationToken, +) -> Result<(), String> { + log::info!( + "listening on {} as host {}", + listener + .local_addr() + .map(|addr| addr.to_string()) + .unwrap_or_default(), + stores.key.public_id() + ); + loop { + let (stream, peer) = tokio::select! { + accepted = listener.accept() => match accepted { + Ok(accepted) => accepted, + Err(error) => { + log::warn!("accept failed: {error}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }, + _ = shutdown.cancelled() => return Ok(()), + }; + let server = Arc::clone(&server); + let stores = Arc::clone(&stores); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + if let Err(error) = handle_connection(stream, peer, server, stores, shutdown).await { + log::info!("connection from {peer} ended: {error}"); + } + }); + } +} + +async fn handle_connection( + stream: TcpStream, + peer: SocketAddr, + server: Arc, + stores: Arc, + shutdown: CancellationToken, +) -> Result<(), String> { + let _ = stream.set_nodelay(true); + // One deadline covers both handshakes. + let deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT; + let socket = tokio::time::timeout_at( + deadline, + tokio_tungstenite::accept_async_with_config(stream, Some(websocket_config())), + ) + .await + .map_err(|_| "handshake timed out".to_string())? + .map_err(|error| format!("websocket accept: {error}"))?; + let pending_code = match stores.pending_pairing.current() { + Some(pending) if stores.limiter.allows(peer.ip()) => Some(pending.code()?), + Some(_) => { + log::warn!("pairing attempts from {peer} are rate limited"); + None + } + None => None, + }; + let devices = Arc::clone(&stores.devices); + let is_paired = move |key: &[u8; 32]| devices.is_paired(&encode_key(key)); + let confirm_pairing = |key: &[u8; 32]| -> Result<(), String> { + let code = pending_code + .as_ref() + .ok_or_else(|| "no pairing code is pending".to_string())?; + stores.pending_pairing.consume_if(code)?; + stores.devices.insert(&encode_key(key), UNNAMED_DEVICE)?; + Ok(()) + }; + let established = tokio::time::timeout_at( + deadline, + noise::respond( + socket, + stores.key.private(), + Respond { + pairing_psk: pending_code.as_ref().map(PairingCode::psk), + is_paired: &is_paired, + confirm_pairing: &confirm_pairing, + }, + ), + ) + .await + .map_err(|_| "handshake timed out".to_string())?; + let established = match established { + Ok(established) => established, + Err(refused) => { + // Only a failed pairing counts against the address: a wrong + // code and a probe of the pairing pattern look the same. A + // session handshake a revoked device keeps retrying must not + // lock its address out of pairing again. + if refused.mode == Some(HandshakeMode::Pair) { + stores.limiter.record_failure(peer.ip()); + } + return Err(refused.message); + } + }; + let device_key = encode_key(&established.remote_static); + if established.mode == HandshakeMode::Pair { + log::info!("paired device {device_key} from {peer}"); + } + let connection = shutdown.child_token(); + // Revocation is an edit to the device file; a revoked device's live + // connection ends at the next check. + let revocation_watch = { + let devices = Arc::clone(&stores.devices); + let key = device_key.clone(); + let connection = connection.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(10)).await; + if !devices.is_paired(&key) { + log::info!("device {key} was revoked; disconnecting"); + connection.cancel(); + return; + } + } + }) + }; + let result = server + .serve_with_peer(established.carrier, Some(device_key), connection) + .await; + revocation_watch.abort(); + result +} diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs new file mode 100644 index 000000000..4293bf535 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -0,0 +1,564 @@ +//! The client's connections to its saved hosts. +//! +//! One connector task per saved host dials the host's connections in +//! order, hands the UI a connected [`RemoteHostBackend`], forwards the +//! host's events, and reconnects with jittered exponential backoff when +//! the connection ends. Pairing dials with a code, saves the host, and +//! starts its connector with the connection already open. Everything the +//! UI needs arrives as [`HostManagerEvent`]s on one channel. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use maple_agent::host::{HostBackend, HostEvent, HostId}; +use rand::Rng as _; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::client::{ClientConfig, RemoteHostBackend}; +use crate::dial::{ConnectTarget, connect_direct}; +use crate::hosts::{HostConnection, HostsStore, SavedHost}; +use crate::keys::StaticKey; +use crate::pairing::PairingCode; +use crate::wire::{ClientHello, version_label}; + +/// Reconnect backoff: full jitter between half and all of an exponential +/// delay from `BACKOFF_FLOOR` to `BACKOFF_CAP`. +const BACKOFF_FLOOR: Duration = Duration::from_secs(1); +const BACKOFF_CAP: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostStatus { + Connecting, + Online, + Offline { reason: String }, +} + +/// What a host announced about its build at the hello. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostVersion { + /// The host's package version. + pub version: String, + /// The git revision it was built from, when its build knew it. + pub build: Option, +} + +impl HostVersion { + /// `0.1.0 (63bcff5c)`, or `0.1.0` without a build. + pub fn label(&self) -> String { + version_label(&self.version, self.build.as_deref()) + } +} + +/// What the manager tells the UI. +#[derive(Clone)] +pub enum HostManagerEvent { + /// A host's connection state changed. `backend` is present exactly + /// when the status is `Online`; the version it announced is answered + /// by [`HostManager::host_version`] and saved on the host's record. + Status { + host: HostId, + name: String, + status: HostStatus, + backend: Option>, + }, + /// The host pushed an event. + Event { host: HostId, event: HostEvent }, + /// The saved host list changed (paired, renamed, removed). + HostsChanged(Vec), +} + +impl std::fmt::Debug for HostManagerEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Status { host, status, .. } => { + write!(f, "Status({host}, {status:?})") + } + Self::Event { host, .. } => write!(f, "Event({host})"), + Self::HostsChanged(hosts) => write!(f, "HostsChanged({})", hosts.len()), + } + } +} + +pub struct HostManager { + device: StaticKey, + /// The hello every connection sends; the device is this client. + hello: ClientHello, + store: Arc, + config: ClientConfig, + events: mpsc::UnboundedSender, + connectors: Mutex>, + /// Hosts with a live connection right now, for callers that did not + /// watch the event stream (the settings screen). + online: Mutex>, + /// What each host with a live connection announced at its hello. + versions: Mutex>, + shutdown: CancellationToken, +} + +impl HostManager { + pub fn new( + device: StaticKey, + hello: ClientHello, + store: Arc, + config: ClientConfig, + ) -> (Arc, mpsc::UnboundedReceiver) { + let (events, receiver) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + device, + hello, + store, + config, + events, + connectors: Mutex::new(HashMap::new()), + online: Mutex::new(HashSet::new()), + versions: Mutex::new(HashMap::new()), + shutdown: CancellationToken::new(), + }), + receiver, + ) + } + + pub fn store(&self) -> &Arc { + &self.store + } + + /// Whether `id` has a live connection right now. + pub fn is_online(&self, id: &str) -> bool { + self.online + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(id) + } + + /// What `id` announced at the hello of its live connection, or `None` + /// while it is offline; the saved host keeps the last seen version. + pub fn host_version(&self, id: &str) -> Option { + self.versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .cloned() + } + + fn set_online(&self, id: &str, online: bool) { + let mut set = self + .online + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if online { + set.insert(id.to_string()); + } else { + set.remove(id); + } + } + + /// Remember what a connection's hello announced, or forget it once the + /// connection is gone. The saved record keeps the last seen version + /// so an offline host still shows what it ran. + fn set_version(&self, id: &str, version: Option) { + let mut versions = self + .versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match version { + Some(version) => { + versions.insert(id.to_string(), version); + } + None => { + versions.remove(id); + } + } + } + + /// Whether `token` still belongs to the connector registered for `id`. + /// A connector that was replaced or removed keeps running until it + /// notices its cancellation; nothing it says after that may reach the + /// UI or the online set, or it would overwrite its successor's state. + fn is_current(&self, id: &str, token: &CancellationToken) -> bool { + self.connectors + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .is_some_and(|current| current == token) + } + + /// Start a connector for every saved host. Must run inside a Tokio + /// runtime. + pub fn start(self: &Arc) { + let hosts = self.store.list().unwrap_or_else(|error| { + log::warn!("cannot read saved hosts: {error}"); + Vec::new() + }); + // The UI learns the full list first, so it can tell a saved host + // that is still connecting from one it does not know at all. + self.emit(HostManagerEvent::HostsChanged(hosts.clone())); + for host in hosts { + self.spawn_connector(host, None); + } + } + + /// Stop every connector. + pub fn shutdown(&self) { + self.shutdown.cancel(); + } + + fn emit(&self, event: HostManagerEvent) { + let _ = self.events.send(event); + } + + /// Pair with the host at `address` using `code`, save it, and start its + /// connector on the connection the pairing opened. + pub async fn pair( + self: &Arc, + address: &str, + code: PairingCode, + name: Option, + ) -> Result { + let address = address.trim().to_string(); + if address.is_empty() { + return Err("Enter the host's address".to_string()); + } + let dialed = connect_direct(&address, &self.device, ConnectTarget::Pair(code)).await?; + let backend = + RemoteHostBackend::connect(dialed.carrier, self.hello.clone(), self.config.clone()) + .await?; + let announced = backend.host_hello().host.clone(); + let (last_seen_version, last_seen_build) = backend.host_version(); + let host = self.store.upsert(SavedHost { + id: dialed.host_key.clone(), + name: name + .filter(|name| !name.trim().is_empty()) + .unwrap_or(announced.name), + connections: vec![HostConnection::Direct { address }], + paired_at_ms: 0, + last_seen_version: Some(last_seen_version), + last_seen_build, + })?; + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + self.spawn_connector(host.clone(), Some(backend)); + Ok(host) + } + + pub fn rename(&self, id: &str, name: &str) -> Result<(), String> { + self.store.rename(id, name)?; + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + Ok(()) + } + + /// Forget a host: its connector stops and its record goes. + pub fn remove(&self, id: &str) -> Result<(), String> { + if let Some(token) = self + .connectors + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(id) + { + token.cancel(); + } + self.set_online(id, false); + self.set_version(id, None); + let name = self + .store + .get(id)? + .map(|host| host.name) + .unwrap_or_default(); + self.store.remove(id)?; + self.emit(HostManagerEvent::Status { + host: HostId::new(id), + name, + status: HostStatus::Offline { + reason: "removed".to_string(), + }, + backend: None, + }); + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + Ok(()) + } + + fn spawn_connector(self: &Arc, host: SavedHost, initial: Option>) { + let token = self.shutdown.child_token(); + if let Some(previous) = self + .connectors + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(host.id.clone(), token.clone()) + { + previous.cancel(); + } + let manager = Arc::clone(self); + tokio::spawn(async move { + manager.run_connector(host, initial, token).await; + }); + } + + async fn run_connector( + &self, + host: SavedHost, + mut initial: Option>, + cancel: CancellationToken, + ) { + let id = HostId::new(host.id.clone()); + // Status and the online set belong to the current connector only. + let status = |name: &str, status: HostStatus, backend: Option>| { + if self.is_current(&host.id, &cancel) { + self.emit(HostManagerEvent::Status { + host: id.clone(), + name: name.to_string(), + status, + backend, + }); + } + }; + let online = |online: Option| { + if self.is_current(&host.id, &cancel) { + self.set_online(&host.id, online.is_some()); + self.set_version(&host.id, online); + } + }; + let mut attempt: u32 = 0; + while !cancel.is_cancelled() { + // Connections may have been added since; read them fresh. + let saved = self + .store + .get(&host.id) + .ok() + .flatten() + .unwrap_or_else(|| host.clone()); + let name = saved.name.clone(); + let backend = match initial.take() { + Some(backend) => Ok(backend), + None => { + status(&name, HostStatus::Connecting, None); + self.dial(&saved, &cancel).await + } + }; + match backend { + Ok(backend) => { + attempt = 0; + let (version, build) = backend.host_version(); + // Every successful hello refreshes the record, so an + // offline host shows the build it ran most recently. + if let Err(error) = + self.store + .record_last_seen(&host.id, &version, build.as_deref()) + { + log::warn!("cannot record the host's version: {error}"); + } + online(Some(HostVersion { version, build })); + status(&name, HostStatus::Online, Some(Arc::clone(&backend))); + let reason = self.forward_events(&id, &backend, &cancel).await; + online(None); + backend.close().await; + status(&name, HostStatus::Offline { reason }, None); + if cancel.is_cancelled() { + return; + } + } + Err(reason) => status(&name, HostStatus::Offline { reason }, None), + } + let delay = backoff(attempt); + attempt = attempt.saturating_add(1); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel.cancelled() => return, + } + } + } + + /// Try each connection in order; the first that completes a handshake + /// wins. Cancelling ends the attempt at once rather than after the + /// dial's own timeout. + async fn dial( + &self, + host: &SavedHost, + cancel: &CancellationToken, + ) -> Result, String> { + if host.connections.is_empty() { + return Err("no address saved for this host".to_string()); + } + let mut last_error = String::new(); + for connection in &host.connections { + let HostConnection::Direct { address } = connection; + let attempt = async { + let dialed = connect_direct( + address, + &self.device, + ConnectTarget::Host { + host_key: host.id.clone(), + }, + ) + .await?; + RemoteHostBackend::connect(dialed.carrier, self.hello.clone(), self.config.clone()) + .await + }; + let outcome = tokio::select! { + outcome = attempt => outcome, + _ = cancel.cancelled() => return Err("cancelled".to_string()), + }; + match outcome { + Ok(backend) => return Ok(backend), + Err(error) => last_error = error, + } + } + Err(last_error) + } + + /// Forward the host's events until the connection ends or the + /// connector is cancelled. Returns why it stopped. + async fn forward_events( + &self, + id: &HostId, + backend: &Arc, + cancel: &CancellationToken, + ) -> String { + let mut events = backend.subscribe(); + let mut closed = backend.closed(); + loop { + tokio::select! { + event = events.recv() => match event { + Some(event) => self.emit(HostManagerEvent::Event { host: id.clone(), event }), + None => return "connection ended".to_string(), + }, + changed = closed.changed() => { + if changed.is_err() { + return "connection ended".to_string(); + } + if let Some(reason) = closed.borrow().clone() { + return reason; + } + } + _ = cancel.cancelled() => return "stopped".to_string(), + } + } + } +} + +/// Full-jitter exponential backoff. +fn backoff(attempt: u32) -> Duration { + let exponential = BACKOFF_FLOOR + .checked_mul(2u32.saturating_pow(attempt.min(10))) + .unwrap_or(BACKOFF_CAP) + .min(BACKOFF_CAP); + let millis = exponential.as_millis() as u64; + let jittered = rand::thread_rng().gen_range((millis / 2).max(1)..=millis); + Duration::from_millis(jittered) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manager( + dir: &std::path::Path, + ) -> (Arc, mpsc::UnboundedReceiver) { + let device = StaticKey::generate().unwrap(); + let hello = ClientHello { + protocol: crate::wire::PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: None, + pcr_environment: "Development".to_string(), + features: crate::wire::features(), + device: crate::wire::DeviceInfo { + public_key: device.public_id(), + name: "test".to_string(), + user_id: None, + }, + }; + let store = Arc::new(HostsStore::new(dir.join("hosts.json"))); + HostManager::new(device, hello, store, ClientConfig::default()) + } + + #[tokio::test] + async fn removing_a_host_drops_its_dial_and_silences_its_connector() { + let dir = std::env::temp_dir().join(format!("maple-manager-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + // A listener that accepts and then never answers the WebSocket + // handshake, so a dial hangs until it is cancelled. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = vec![0u8; 4096]; + // The WebSocket request arrives; then nothing more until EOF. + let mut total = 0; + loop { + match tokio::io::AsyncReadExt::read(&mut stream, &mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => total += read, + } + } + let _ = accepted_tx.send(total); + }); + + let (manager, mut events) = manager(&dir); + let host = manager + .store() + .upsert(SavedHost { + id: StaticKey::generate().unwrap().public_id(), + name: "slow".to_string(), + connections: vec![HostConnection::Direct { address }], + paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, + }) + .unwrap(); + manager.start(); + // The connector announces it is connecting, then hangs in the dial. + let started = std::time::Instant::now(); + loop { + let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + if let HostManagerEvent::Status { + status: HostStatus::Connecting, + .. + } = event + { + break; + } + } + manager.remove(&host.id).unwrap(); + // The peer sees EOF as soon as the dial is dropped, long before the + // handshake timeout. How much of the WebSocket request was written + // first depends on scheduling and does not matter. + let _read = tokio::time::timeout(Duration::from_secs(5), accepted_rx) + .await + .expect("the dial was dropped at once") + .unwrap(); + assert!(started.elapsed() < Duration::from_secs(5)); + // After the removal notice the old connector says nothing more. + let mut statuses = Vec::new(); + while let Ok(Some(event)) = + tokio::time::timeout(Duration::from_millis(300), events.recv()).await + { + if let HostManagerEvent::Status { status, .. } = event { + statuses.push(status); + } + } + assert_eq!( + statuses, + vec![HostStatus::Offline { + reason: "removed".to_string() + }] + ); + assert!(!manager.is_online(&host.id)); + manager.shutdown(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn backoff_grows_to_the_cap_and_stays_jittered() { + for attempt in 0..12 { + let delay = backoff(attempt); + assert!(delay >= BACKOFF_FLOOR / 2, "attempt {attempt}: {delay:?}"); + assert!(delay <= BACKOFF_CAP, "attempt {attempt}: {delay:?}"); + } + assert!(backoff(0) <= BACKOFF_FLOOR); + assert!(backoff(10) >= BACKOFF_CAP / 2); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/net.rs b/apps/maple-agent/crates/maple-remote/src/net.rs new file mode 100644 index 000000000..871db5d6b --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/net.rs @@ -0,0 +1,34 @@ +//! What both network roles share: the WebSocket configuration and the +//! handshake budget. The host role is [`crate::listen`], the client role +//! [`crate::dial`]. + +use std::time::Duration; + +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; + +/// Time a peer gets to finish the WebSocket and Noise handshakes. +pub(crate) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Largest WebSocket message either side reads. A Noise message is at +/// most 65535 bytes, so anything larger is not this protocol and is +/// refused before it is buffered. +pub const MAX_WEBSOCKET_MESSAGE_BYTES: usize = 65535; + +/// The WebSocket configuration both roles use. +pub fn websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_message_size(Some(MAX_WEBSOCKET_MESSAGE_BYTES)) + .max_frame_size(Some(MAX_WEBSOCKET_MESSAGE_BYTES)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_messages_are_capped_at_one_noise_message() { + let config = websocket_config(); + assert_eq!(config.max_message_size, Some(65535)); + assert_eq!(config.max_frame_size, Some(65535)); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/noise.rs b/apps/maple-agent/crates/maple-remote/src/noise.rs new file mode 100644 index 000000000..1a075f411 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/noise.rs @@ -0,0 +1,558 @@ +//! Noise inside a WebSocket: the encrypted carrier. +//! +//! Two handshakes. Pairing runs `XXpsk3` with the one-time code as the +//! pre-shared key: both sides send their static keys, and the code is what +//! authenticates the exchange. Every later connection runs `IK`: the client +//! knows the host's static key, sends its own encrypted in the first +//! message, and the host accepts it only if that key is paired. After +//! either handshake both sides hold the other's static key to pin. +//! +//! A relay in between sees only the handshake's ciphertext and the +//! transport messages. The first byte of the first message names the +//! handshake and is also the Noise prologue, so a relay cannot swap one +//! for the other. +//! +//! In the pairing pattern the client sends the last handshake message, so +//! it could not tell a wrong code from success until the host dropped it. +//! The host therefore sends one empty transport message once its side +//! completes and it has spent the code and recorded the device; the +//! client must decrypt it before it trusts the session. +//! +//! Noise transport messages hold at most 65535 bytes, so a frame is cut +//! into pieces; each piece carries one continuation byte before the +//! frame bytes. Every WebSocket binary message is exactly one Noise message. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; + +use crate::carrier::{Carrier, FrameSink, FrameStream}; +use crate::frame::{Frame, HEADER_BYTES, MAX_CONTROL_FRAME_BYTES}; + +/// Pairing: statics exchanged, authenticated by the pre-shared code. +pub const PAIRING_PATTERN: &str = "Noise_XXpsk3_25519_ChaChaPoly_BLAKE2s"; +/// Every later connection: the host's static is known and pinned. +pub const SESSION_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; + +const MODE_PAIR: u8 = 1; +const MODE_SESSION: u8 = 2; +const PROLOGUE_PAIR: &[u8] = b"maple-remote-v1/pair"; +const PROLOGUE_SESSION: &[u8] = b"maple-remote-v1/session"; + +/// Largest plaintext one Noise message carries: 65535 minus the 16-byte +/// tag, minus the continuation byte. +const PIECE_BYTES: usize = 65535 - 16 - 1; +const MORE: u8 = 1; +const LAST: u8 = 0; + +/// Largest frame a peer may reassemble from pieces: the biggest control +/// frame plus its header. Stream frames are smaller still. +const MAX_REASSEMBLED_BYTES: usize = HEADER_BYTES + MAX_CONTROL_FRAME_BYTES; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandshakeMode { + Pair, + Session, +} + +impl HandshakeMode { + fn byte(self) -> u8 { + match self { + Self::Pair => MODE_PAIR, + Self::Session => MODE_SESSION, + } + } + + fn from_byte(byte: u8) -> Option { + match byte { + MODE_PAIR => Some(Self::Pair), + MODE_SESSION => Some(Self::Session), + _ => None, + } + } + + fn pattern(self) -> &'static str { + match self { + Self::Pair => PAIRING_PATTERN, + Self::Session => SESSION_PATTERN, + } + } + + fn prologue(self) -> &'static [u8] { + match self { + Self::Pair => PROLOGUE_PAIR, + Self::Session => PROLOGUE_SESSION, + } + } +} + +/// What the initiator brings to a handshake. +pub enum Initiate { + /// Pair with the code the host published. + Pair { psk: [u8; 32] }, + /// Connect to a host whose static key is pinned. + Session { host_static: [u8; 32] }, +} + +/// What the responder needs to answer a handshake. +pub struct Respond<'a> { + /// The pre-shared key for a pairing attempt, when one is pending. + pub pairing_psk: Option<[u8; 32]>, + /// Whether a device's static key is paired, for a session handshake. + pub is_paired: &'a (dyn Fn(&[u8; 32]) -> bool + Send + Sync), + /// Called with the device's static key once a pairing handshake + /// completes and before the client is told its code was right. It + /// spends the code and records the device; an error refuses the + /// pairing, so two clients racing on one code cannot both succeed. + pub confirm_pairing: &'a (dyn Fn(&[u8; 32]) -> Result<(), String> + Send + Sync), +} + +/// Why the host side of a handshake failed. +#[derive(Debug)] +pub struct HandshakeRefused { + /// The handshake the client asked for, once its first byte was read. + /// `None` when the failure came before that. + pub mode: Option, + pub message: String, +} + +impl std::fmt::Display for HandshakeRefused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// The outcome of a handshake: the encrypted carrier and the peer's static +/// key. +pub struct Established { + pub carrier: Carrier, + pub remote_static: [u8; 32], + pub mode: HandshakeMode, +} + +fn builder<'a>( + mode: HandshakeMode, + local_private: &'a [u8; 32], +) -> Result, String> { + let params = mode + .pattern() + .parse() + .map_err(|error| format!("noise pattern: {error}"))?; + snow::Builder::new(params) + .prologue(mode.prologue()) + .map_err(|error| format!("noise prologue: {error}"))? + .local_private_key(local_private) + .map_err(|error| format!("noise local key: {error}")) +} + +fn remote_static(state: &snow::HandshakeState) -> Result<[u8; 32], String> { + state + .get_remote_static() + .and_then(|key| key.try_into().ok()) + .ok_or_else(|| "the peer sent no static key".to_string()) +} + +/// Run the client side of a handshake over `socket`. +pub async fn initiate( + mut socket: WebSocketStream, + local_private: &[u8; 32], + initiate: Initiate, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (mode, mut state) = match initiate { + Initiate::Pair { psk } => ( + HandshakeMode::Pair, + builder(HandshakeMode::Pair, local_private)? + .psk(3, &psk) + .map_err(|error| format!("noise psk: {error}"))? + .build_initiator() + .map_err(|error| format!("noise: {error}"))?, + ), + Initiate::Session { host_static } => ( + HandshakeMode::Session, + builder(HandshakeMode::Session, local_private)? + .remote_public_key(&host_static) + .map_err(|error| format!("noise remote key: {error}"))? + .build_initiator() + .map_err(|error| format!("noise: {error}"))?, + ), + }; + let mut buffer = vec![0u8; 1024]; + let mut first = true; + while !state.is_handshake_finished() { + if state.is_my_turn() { + let written = state + .write_message(&[], &mut buffer) + .map_err(|error| format!("noise handshake: {error}"))?; + let mut message = Vec::with_capacity(written + 1); + if first { + message.push(mode.byte()); + first = false; + } + message.extend_from_slice(&buffer[..written]); + socket + .send(Message::Binary(message.into())) + .await + .map_err(|error| format!("cannot send the handshake: {error}"))?; + } else { + let message = next_binary(&mut socket) + .await? + .ok_or_else(|| "the host closed during the handshake".to_string())?; + state.read_message(&message, &mut buffer).map_err(|_| { + "the host refused the handshake: wrong pairing code, or this device is not paired" + .to_string() + })?; + } + } + let remote = remote_static(&state)?; + let mut transport = state + .into_transport_mode() + .map_err(|error| format!("noise transport: {error}"))?; + if mode == HandshakeMode::Pair { + let confirmation = next_binary(&mut socket) + .await? + .ok_or_else(|| "the host refused the pairing code".to_string())?; + let mut out = vec![0u8; confirmation.len()]; + let read = transport + .read_message(&confirmation, &mut out) + .map_err(|_| "the host refused the pairing code".to_string())?; + if read != 0 { + return Err("unexpected data before the pairing completed".to_string()); + } + } + Ok(Established { + carrier: carrier(socket, transport), + remote_static: remote, + mode, + }) +} + +/// Run the host side of a handshake over `socket`. +pub async fn respond( + mut socket: WebSocketStream, + local_private: &[u8; 32], + respond: Respond<'_>, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let before_mode = |message: String| HandshakeRefused { + mode: None, + message, + }; + let first = next_binary(&mut socket) + .await + .map_err(before_mode)? + .ok_or_else(|| before_mode("the client closed before the handshake".to_string()))?; + let (&mode_byte, first_message) = first + .split_first() + .ok_or_else(|| before_mode("empty handshake message".to_string()))?; + let mode = HandshakeMode::from_byte(mode_byte) + .ok_or_else(|| before_mode(format!("unknown handshake mode {mode_byte}")))?; + respond_as(socket, local_private, respond, mode, first_message) + .await + .map_err(|message| HandshakeRefused { + mode: Some(mode), + message, + }) +} + +/// The rest of the host side once the handshake's mode is known. +async fn respond_as( + mut socket: WebSocketStream, + local_private: &[u8; 32], + respond: Respond<'_>, + mode: HandshakeMode, + first_message: &[u8], +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let mut state = match mode { + HandshakeMode::Pair => { + let psk = respond + .pairing_psk + .ok_or_else(|| "no pairing code is pending".to_string())?; + builder(mode, local_private)? + .psk(3, &psk) + .map_err(|error| format!("noise psk: {error}"))? + .build_responder() + .map_err(|error| format!("noise: {error}"))? + } + HandshakeMode::Session => builder(mode, local_private)? + .build_responder() + .map_err(|error| format!("noise: {error}"))?, + }; + let mut buffer = vec![0u8; 1024]; + state + .read_message(first_message, &mut buffer) + .map_err(|error| format!("handshake refused: {error}"))?; + if mode == HandshakeMode::Session { + // IK carries the client's static in its first message; refuse an + // unpaired device before answering anything. + let key = remote_static(&state)?; + if !(respond.is_paired)(&key) { + return Err("this device is not paired with the host".to_string()); + } + } + while !state.is_handshake_finished() { + if state.is_my_turn() { + let written = state + .write_message(&[], &mut buffer) + .map_err(|error| format!("noise handshake: {error}"))?; + socket + .send(Message::Binary(buffer[..written].to_vec().into())) + .await + .map_err(|error| format!("cannot send the handshake: {error}"))?; + } else { + let message = next_binary(&mut socket) + .await? + .ok_or_else(|| "the client closed during the handshake".to_string())?; + state + .read_message(&message, &mut buffer) + .map_err(|error| format!("handshake refused: {error}"))?; + } + } + let remote = remote_static(&state)?; + let mut transport = state + .into_transport_mode() + .map_err(|error| format!("noise transport: {error}"))?; + if mode == HandshakeMode::Pair { + // Spend the code and record the device, then tell the client its + // code was right; see the module docs. + (respond.confirm_pairing)(&remote)?; + let mut out = vec![0u8; 16]; + let written = transport + .write_message(&[], &mut out) + .map_err(|error| format!("noise confirm: {error}"))?; + socket + .send(Message::Binary(out[..written].to_vec().into())) + .await + .map_err(|error| format!("cannot confirm the pairing: {error}"))?; + } + Ok(Established { + carrier: carrier(socket, transport), + remote_static: remote, + mode, + }) +} + +async fn next_binary(socket: &mut WebSocketStream) -> Result>, String> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + match socket.next().await { + Some(Ok(Message::Binary(bytes))) => return Ok(Some(bytes.to_vec())), + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Ok(_)) => continue, + Some(Err(error)) => return Err(format!("websocket: {error}")), + } + } +} + +type Transport = Arc>; + +fn carrier(socket: WebSocketStream, transport: snow::TransportState) -> Carrier +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let transport = Arc::new(Mutex::new(transport)); + let (sink, stream) = socket.split(); + Carrier { + sink: Box::new(NoiseSink { + sink: Some(sink), + transport: Arc::clone(&transport), + }), + stream: Box::new(NoiseStream { + stream, + transport, + reassembly: Reassembly::default(), + }), + } +} + +struct NoiseSink { + sink: Option, Message>>, + transport: Transport, +} + +#[async_trait] +impl FrameSink for NoiseSink +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + async fn send(&mut self, frame: Frame) -> Result<(), String> { + let sink = self + .sink + .as_mut() + .ok_or_else(|| "carrier closed".to_string())?; + let encoded = frame.encode(); + let pieces: Vec<&[u8]> = if encoded.is_empty() { + vec![&[][..]] + } else { + encoded.chunks(PIECE_BYTES).collect() + }; + let count = pieces.len(); + for (index, piece) in pieces.into_iter().enumerate() { + let mut plaintext = Vec::with_capacity(piece.len() + 1); + plaintext.push(if index + 1 == count { LAST } else { MORE }); + plaintext.extend_from_slice(piece); + let ciphertext = { + let mut transport = self + .transport + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut out = vec![0u8; plaintext.len() + 16]; + let written = transport + .write_message(&plaintext, &mut out) + .map_err(|error| format!("noise encrypt: {error}"))?; + out.truncate(written); + out + }; + sink.send(Message::Binary(ciphertext.into())) + .await + .map_err(|error| format!("websocket send: {error}"))?; + } + Ok(()) + } + + async fn close(&mut self) { + if let Some(mut sink) = self.sink.take() { + let _ = sink.send(Message::Close(None)).await; + let _ = sink.close().await; + } + } +} + +/// The frame being put back together from its pieces. +#[derive(Default)] +struct Reassembly { + partial: Vec, +} + +impl Reassembly { + /// Take one decrypted piece. `Ok(Some(_))` is a whole frame's bytes, + /// `Ok(None)` means more pieces follow (an empty piece is ignored), and + /// `Err` means the peer is sending a frame larger than any it may send. + fn push(&mut self, plaintext: &[u8]) -> Result, String> { + let Some((&flag, piece)) = plaintext.split_first() else { + return Ok(None); + }; + if self.partial.len() + piece.len() > MAX_REASSEMBLED_BYTES { + self.partial = Vec::new(); + return Err(format!( + "frame grew past the {MAX_REASSEMBLED_BYTES} byte limit while reassembling" + )); + } + self.partial.extend_from_slice(piece); + if flag == MORE { + return Ok(None); + } + Ok(Some(Bytes::from(std::mem::take(&mut self.partial)))) + } +} + +struct NoiseStream { + stream: futures_util::stream::SplitStream>, + transport: Transport, + reassembly: Reassembly, +} + +#[async_trait] +impl FrameStream for NoiseStream +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + async fn recv(&mut self) -> Option { + loop { + let ciphertext = match self.stream.next().await { + Some(Ok(Message::Binary(bytes))) => bytes, + Some(Ok(Message::Close(_))) | None => return None, + Some(Ok(_)) => continue, + Some(Err(error)) => { + log::debug!("websocket receive: {error}"); + return None; + } + }; + let plaintext = { + let mut transport = self + .transport + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut out = vec![0u8; ciphertext.len()]; + match transport.read_message(&ciphertext, &mut out) { + Ok(read) => { + out.truncate(read); + out + } + Err(error) => { + log::warn!("noise decrypt failed; closing: {error}"); + return None; + } + } + }; + let bytes = match self.reassembly.push(&plaintext) { + Ok(Some(bytes)) => bytes, + Ok(None) => continue, + Err(error) => { + log::warn!("bad frame; closing: {error}"); + return None; + } + }; + match Frame::decode(bytes) { + Ok(frame) => return Some(frame), + Err(error) => { + log::warn!("bad frame; closing: {error}"); + return None; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reassembly_rejects_a_frame_past_the_control_limit() { + let mut reassembly = Reassembly::default(); + assert_eq!(reassembly.push(&[]).unwrap(), None); + let mut more = vec![MORE]; + more.extend_from_slice(&[1, 2]); + assert_eq!(reassembly.push(&more).unwrap(), None); + let mut last = vec![LAST]; + last.extend_from_slice(&[3]); + assert_eq!( + reassembly.push(&last).unwrap(), + Some(Bytes::from_static(&[1, 2, 3])) + ); + + let mut piece = vec![MORE]; + piece.extend(std::iter::repeat_n(0u8, PIECE_BYTES)); + let mut total = 0; + let error = loop { + match reassembly.push(&piece) { + Ok(None) => total += PIECE_BYTES, + Ok(Some(_)) => panic!("MORE pieces never complete a frame"), + Err(error) => break error, + } + }; + assert!(total <= MAX_REASSEMBLED_BYTES); + assert!(total + PIECE_BYTES > MAX_REASSEMBLED_BYTES); + assert!(error.contains("limit"), "{error}"); + // The partial frame is dropped with the error. + assert_eq!( + reassembly.push(&last).unwrap(), + Some(Bytes::from_static(&[3])) + ); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/outbound.rs b/apps/maple-agent/crates/maple-remote/src/outbound.rs new file mode 100644 index 000000000..b217e8880 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/outbound.rs @@ -0,0 +1,155 @@ +//! The bounded outbound queue in front of a carrier. +//! +//! Every frame a side sends goes through one queue that counts queued +//! bytes. A frame that would push the count past the limit closes the +//! connection: the peer has stopped draining, and the host must never +//! wait on a client. The peer comes back through its ordinary reconnect +//! and resync path. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use tokio::sync::mpsc; + +use crate::frame::{Frame, HEADER_BYTES, max_payload_bytes}; + +/// Default byte limit for one connection's outbound queue. +pub const DEFAULT_MAX_OUTBOUND_BYTES: usize = 64 * 1024 * 1024; + +struct Shared { + queued_bytes: AtomicUsize, + limit: usize, + overflowed: AtomicBool, +} + +/// The sending handle. Cheap to clone; every clone shares the budget. +#[derive(Clone)] +pub struct Outbound { + tx: mpsc::UnboundedSender, + shared: Arc, +} + +/// The draining end, owned by the writer task. +pub struct OutboundQueue { + rx: mpsc::UnboundedReceiver, + shared: Arc, +} + +/// Create a queue with a byte limit. +pub fn channel(max_bytes: usize) -> (Outbound, OutboundQueue) { + let (tx, rx) = mpsc::unbounded_channel(); + let shared = Arc::new(Shared { + queued_bytes: AtomicUsize::new(0), + limit: max_bytes, + overflowed: AtomicBool::new(false), + }); + ( + Outbound { + tx, + shared: Arc::clone(&shared), + }, + OutboundQueue { rx, shared }, + ) +} + +impl Outbound { + /// Queue a frame. Fails when the frame is larger than the peer would + /// accept, when the queue is closed, or when the frame would overflow + /// the budget; an overflow also marks the connection for closing. + pub fn try_send(&self, frame: Frame) -> Result<(), String> { + let limit = max_payload_bytes(frame.channel); + if frame.payload.len() > limit { + return Err(format!( + "frame of {} bytes on channel {} exceeds the {limit} byte limit", + frame.payload.len(), + frame.channel + )); + } + let bytes = frame.payload.len() + HEADER_BYTES; + let queued = self.shared.queued_bytes.fetch_add(bytes, Ordering::AcqRel) + bytes; + if queued > self.shared.limit { + self.shared.queued_bytes.fetch_sub(bytes, Ordering::AcqRel); + self.shared.overflowed.store(true, Ordering::Release); + return Err(format!( + "outbound queue over its {} byte limit; closing the connection", + self.shared.limit + )); + } + self.tx.send(frame).map_err(|_| { + self.shared.queued_bytes.fetch_sub(bytes, Ordering::AcqRel); + "connection closed".to_string() + }) + } + + /// True once a frame overflowed the budget. + pub fn overflowed(&self) -> bool { + self.shared.overflowed.load(Ordering::Acquire) + } +} + +impl OutboundQueue { + /// The next frame to write, with its bytes released from the budget. + pub async fn recv(&mut self) -> Option { + let frame = self.rx.recv().await?; + self.release(&frame); + Some(frame) + } + + /// A frame already queued, without waiting for one. + pub fn try_recv(&mut self) -> Option { + let frame = self.rx.try_recv().ok()?; + self.release(&frame); + Some(frame) + } + + fn release(&self, frame: &Frame) { + self.shared + .queued_bytes + .fetch_sub(frame.payload.len() + HEADER_BYTES, Ordering::AcqRel); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn overflow_refuses_the_frame_and_marks_the_connection() { + let (out, mut queue) = channel(100); + out.try_send(Frame::control(vec![0u8; 50])).unwrap(); + assert!(!out.overflowed()); + assert!(out.try_send(Frame::control(vec![0u8; 60])).is_err()); + assert!(out.overflowed()); + // Draining releases the budget. + assert_eq!(queue.recv().await.unwrap().payload.len(), 50); + out.try_send(Frame::control(vec![0u8; 60])).unwrap(); + drop(queue); + assert!(out.try_send(Frame::control("late")).is_err()); + } + + #[tokio::test] + async fn an_oversized_frame_fails_at_the_sender_without_overflowing() { + use crate::frame::{FrameKind, MAX_CONTROL_FRAME_BYTES, MAX_STREAM_FRAME_BYTES}; + let (out, mut queue) = channel(usize::MAX); + let error = out + .try_send(Frame::control(vec![0u8; MAX_CONTROL_FRAME_BYTES + 1])) + .unwrap_err(); + assert!(error.contains("exceeds"), "{error}"); + assert!(!out.overflowed(), "a refused frame is not an overflow"); + assert!( + out.try_send(Frame { + channel: 4, + kind: FrameKind::Data, + payload: vec![0u8; MAX_STREAM_FRAME_BYTES + 1].into(), + }) + .is_err() + ); + out.try_send(Frame::control(vec![0u8; MAX_CONTROL_FRAME_BYTES])) + .unwrap(); + assert_eq!( + queue.recv().await.unwrap().payload.len(), + MAX_CONTROL_FRAME_BYTES, + "only the frame within the limit was queued" + ); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/pairing.rs b/apps/maple-agent/crates/maple-remote/src/pairing.rs new file mode 100644 index 000000000..f28790a39 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/pairing.rs @@ -0,0 +1,433 @@ +//! One-time pairing codes and the host's pending pairing record. +//! +//! A code is 80 bits of randomness shown as sixteen Crockford base32 +//! characters. It is the pre-shared key of one pairing handshake, valid for +//! five minutes and consumed by the first success. The host reads it from a +//! private file its `pair` command writes, so a running host needs no +//! restart to accept a new device. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::now_ms; + +const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +/// Characters in a code: 16 × 5 bits = 80 bits. +pub const CODE_CHARS: usize = 16; +/// How long a published code stays valid. +pub const CODE_TTL: Duration = Duration::from_secs(5 * 60); +const PSK_DOMAIN: &[u8] = b"maple-pairing-v1"; + +#[derive(Clone, PartialEq, Eq)] +pub struct PairingCode(String); + +/// The code is a secret; `Debug` never shows it. +impl std::fmt::Debug for PairingCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PairingCode(..)") + } +} + +impl PairingCode { + pub fn generate() -> Self { + let mut bytes = [0u8; 10]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let mut code = String::with_capacity(CODE_CHARS); + let mut acc: u32 = 0; + let mut bits = 0; + for byte in bytes { + acc = (acc << 8) | byte as u32; + bits += 8; + while bits >= 5 { + bits -= 5; + code.push(ALPHABET[((acc >> bits) & 31) as usize] as char); + } + } + Self(code) + } + + /// Accept what a person typed: any case, with or without dashes or + /// spaces, and the usual Crockford confusables. + pub fn parse(input: &str) -> Result { + let mut code = String::with_capacity(CODE_CHARS); + for ch in input.chars() { + let ch = match ch.to_ascii_uppercase() { + '-' | ' ' => continue, + 'O' => '0', + 'I' | 'L' => '1', + other => other, + }; + let byte = u8::try_from(ch).ok().filter(|byte| ALPHABET.contains(byte)); + let Some(byte) = byte else { + return Err(format!("'{ch}' is not part of a pairing code")); + }; + code.push(byte as char); + } + if code.chars().count() != CODE_CHARS { + return Err(format!("a pairing code has {CODE_CHARS} characters")); + } + Ok(Self(code)) + } + + /// The code grouped for reading aloud. + pub fn display(&self) -> String { + self.0 + .as_bytes() + .chunks(4) + .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) + .collect::>() + .join("-") + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The pre-shared key for the pairing handshake. + pub fn psk(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(PSK_DOMAIN); + hasher.update(self.0.as_bytes()); + hasher.finalize().into() + } +} + +/// The code a host currently accepts, as stored on disk. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingPairing { + pub code: String, + pub created_ms: u64, + pub expires_ms: u64, +} + +/// The code is a secret; `Debug` shows only the validity window. +impl std::fmt::Debug for PendingPairing { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PendingPairing") + .field("created_ms", &self.created_ms) + .field("expires_ms", &self.expires_ms) + .finish_non_exhaustive() + } +} + +impl PendingPairing { + pub fn code(&self) -> Result { + PairingCode::parse(&self.code) + } + + pub fn is_valid_at(&self, now_ms: u64) -> bool { + now_ms < self.expires_ms + } +} + +/// The private file that holds the pending code. +pub struct PendingPairingStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl PendingPairingStore { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Mutex::new(()), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Publish a fresh code, replacing any pending one. + pub fn publish(&self, code: &PairingCode) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = now_ms(); + let pending = PendingPairing { + code: code.as_str().to_string(), + created_ms: now, + expires_ms: now + CODE_TTL.as_millis() as u64, + }; + maple_agent::private_file::write_private_json(&self.path, &pending) + .map_err(|error| format!("cannot write {}: {error}", self.path.display()))?; + Ok(pending) + } + + /// The pending code when one is valid. An expired record is removed. + pub fn current(&self) -> Option { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.read_valid() + } + + /// The record on disk when it is still valid; an expired one is + /// removed. Callers hold the lock. + fn read_valid(&self) -> Option { + let bytes = std::fs::read(&self.path).ok()?; + let pending: PendingPairing = serde_json::from_slice(&bytes).ok()?; + if pending.is_valid_at(now_ms()) { + Some(pending) + } else { + let _ = std::fs::remove_file(&self.path); + None + } + } + + /// Spend the pending code without checking which one it is: the + /// operator withdrew it. + pub fn consume(&self) { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _ = std::fs::remove_file(&self.path); + } + + /// A pairing with `code` completed: spend the code if it is still the + /// pending one. Fails when it was already spent or replaced, so of two + /// pairings racing on one code exactly one succeeds. + pub fn consume_if(&self, code: &PairingCode) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let stored = self + .read_valid() + .and_then(|pending| pending.code().ok()) + .filter(|pending| pending == code); + if stored.is_none() { + return Err("the pairing code was already used".to_string()); + } + std::fs::remove_file(&self.path) + .map_err(|error| format!("cannot spend the pairing code: {error}")) + } +} + +/// Pairing attempts per source address. A failed pairing handshake is one +/// attempt; too many in the window lock that address out until the window +/// passes. Session handshakes never count, so a revoked device that keeps +/// reconnecting does not lock its address out of pairing again. +pub struct PairingLimiter { + attempts: Mutex>>, + max_attempts: usize, + window: Duration, +} + +/// Addresses remembered at once. Past this the address with the oldest +/// latest failure is forgotten, so a flood of sources cannot grow the map +/// without bound. +pub const MAX_TRACKED_ADDRESSES: usize = 1024; + +impl Default for PairingLimiter { + fn default() -> Self { + Self::new(5, Duration::from_secs(10 * 60)) + } +} + +impl PairingLimiter { + pub fn new(max_attempts: usize, window: Duration) -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + max_attempts, + window, + } + } + + /// Whether `ip` may try now. + pub fn allows(&self, ip: IpAddr) -> bool { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(recent) = attempts.get_mut(&ip) else { + return true; + }; + let now = Instant::now(); + recent.retain(|at| now.duration_since(*at) < self.window); + if recent.is_empty() { + attempts.remove(&ip); + return true; + } + recent.len() < self.max_attempts + } + + /// A pairing handshake from `ip` failed. + pub fn record_failure(&self, ip: IpAddr) { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = Instant::now(); + if !attempts.contains_key(&ip) { + attempts.retain(|_, recent| { + recent.retain(|at| now.duration_since(*at) < self.window); + !recent.is_empty() + }); + if attempts.len() >= MAX_TRACKED_ADDRESSES { + let oldest = attempts + .iter() + .min_by_key(|(_, recent)| recent.iter().max().copied()) + .map(|(ip, _)| *ip); + if let Some(oldest) = oldest { + attempts.remove(&oldest); + } + } + } + attempts.entry(ip).or_default().push(now); + } + + /// Addresses with a failure still inside the window. + pub fn tracked_addresses(&self) -> usize { + self.attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_have_sixteen_characters_and_parse_leniently() { + let code = PairingCode::generate(); + assert_eq!(code.as_str().len(), CODE_CHARS); + assert!(code.as_str().bytes().all(|b| ALPHABET.contains(&b))); + let shown = code.display(); + assert_eq!(shown.len(), CODE_CHARS + 3); + assert_eq!(PairingCode::parse(&shown).unwrap(), code); + assert_eq!( + PairingCode::parse(&shown.to_lowercase().replace('-', " ")).unwrap(), + code + ); + assert_eq!( + PairingCode::parse("oOiIlL1100AAAAAA").unwrap().as_str(), + "0011111100AAAAAA" + ); + assert!(PairingCode::parse("TOO-SHORT").is_err()); + assert!( + PairingCode::parse("UUUUUUUUUUUUUUUU").is_err(), + "U is not in the alphabet" + ); + assert_ne!(code.psk(), PairingCode::generate().psk()); + assert_eq!(code.psk(), PairingCode::parse(&shown).unwrap().psk()); + } + + #[test] + fn non_ascii_input_is_refused_and_debug_hides_the_code() { + // U+0150 truncates to 0x50, 'P', which is in the alphabet. + let error = PairingCode::parse("\u{150}000000000000000").unwrap_err(); + assert!(error.contains("not part of"), "{error}"); + // Sixteen characters, one of them multi-byte: not a length error. + let error = PairingCode::parse("000000000000000\u{e9}").unwrap_err(); + assert!(error.contains("not part of"), "{error}"); + assert!(PairingCode::parse("0000000000000000").is_ok()); + + let code = PairingCode::generate(); + let shown = format!("{code:?}"); + assert!(!shown.contains(code.as_str()), "{shown}"); + let pending = PendingPairing { + code: code.as_str().to_string(), + created_ms: 1, + expires_ms: 2, + }; + let shown = format!("{pending:?}"); + assert!(!shown.contains(code.as_str()), "{shown}"); + assert!(shown.contains("expires_ms"), "{shown}"); + } + + #[test] + fn a_code_is_spent_only_by_the_pairing_that_used_it() { + let dir = std::env::temp_dir().join(format!("maple-pairing-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = PendingPairingStore::new(dir.join("pending.json")); + let code = PairingCode::generate(); + let other = PairingCode::generate(); + assert!(store.consume_if(&code).is_err(), "nothing pending"); + store.publish(&code).unwrap(); + assert!(store.consume_if(&other).is_err(), "a different code"); + assert!(store.current().is_some(), "the pending code survives"); + store.consume_if(&code).unwrap(); + assert!(store.current().is_none()); + assert!( + store.consume_if(&code).is_err(), + "the second pairing on one code loses" + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn pending_codes_expire_and_are_consumed() { + let dir = std::env::temp_dir().join(format!("maple-pairing-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = PendingPairingStore::new(dir.join("pending.json")); + assert!(store.current().is_none()); + let code = PairingCode::generate(); + let pending = store.publish(&code).unwrap(); + assert_eq!(store.current().unwrap().code, code.as_str()); + assert!(pending.is_valid_at(pending.created_ms)); + assert!(!pending.is_valid_at(pending.expires_ms)); + store.consume(); + assert!(store.current().is_none()); + let mut expired = store.publish(&code).unwrap(); + expired.expires_ms = 0; + maple_agent::private_file::write_private_json(store.path(), &expired).unwrap(); + assert!(store.current().is_none()); + assert!(!store.path().exists(), "an expired record is removed"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_limiter_locks_an_address_out_after_repeated_failures() { + let limiter = PairingLimiter::new(2, Duration::from_secs(60)); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let other: IpAddr = "10.0.0.2".parse().unwrap(); + assert!(limiter.allows(ip)); + limiter.record_failure(ip); + assert!(limiter.allows(ip)); + limiter.record_failure(ip); + assert!(!limiter.allows(ip)); + assert!(limiter.allows(other)); + assert_eq!(limiter.tracked_addresses(), 1, "asking never adds an entry"); + } + + #[test] + fn the_limiter_forgets_quiet_addresses_and_caps_how_many_it_tracks() { + let limiter = PairingLimiter::new(2, Duration::from_millis(1)); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + limiter.record_failure(ip); + limiter.record_failure(ip); + assert!(!limiter.allows(ip)); + std::thread::sleep(Duration::from_millis(5)); + assert!(limiter.allows(ip), "the window passed"); + assert_eq!(limiter.tracked_addresses(), 0, "an empty entry is pruned"); + + let limiter = PairingLimiter::new(2, Duration::from_secs(60)); + let first: IpAddr = "10.1.0.0".parse().unwrap(); + limiter.record_failure(first); + limiter.record_failure(first); + assert!(!limiter.allows(first)); + for index in 1..=MAX_TRACKED_ADDRESSES as u32 { + let ip = IpAddr::from(std::net::Ipv4Addr::from(0x0a01_0000 + index)); + limiter.record_failure(ip); + } + assert_eq!(limiter.tracked_addresses(), MAX_TRACKED_ADDRESSES); + assert!( + limiter.allows(first), + "the address with the oldest failure was forgotten" + ); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/rpc.rs b/apps/maple-agent/crates/maple-remote/src/rpc.rs new file mode 100644 index 000000000..86036a342 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/rpc.rs @@ -0,0 +1,219 @@ +//! JSON-RPC 2.0 on the control channel. +//! +//! Requests carry a numeric id and get exactly one response. Notifications +//! (no id) carry host events in one direction and the keepalive in both. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Error codes. The JSON-RPC reserved range is honored; ours start at +/// -32000 as the spec allows. There is no parse error: a control frame +/// that is not a JSON-RPC message closes the connection instead of being +/// answered, because nothing in it can be trusted to carry an id. +pub mod code { + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const INVALID_PARAMS: i64 = -32602; + /// The host refused the handshake: mismatched environment or + /// protocol. The connection closes after this answer. + pub const HANDSHAKE_REFUSED: i64 = -32000; + /// The method requires a handshake first. + pub const NOT_READY: i64 = -32001; + /// The host's `HostBackend` returned an error; the message is the + /// user-facing text. + pub const HOST_ERROR: i64 = -32002; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RpcError { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl RpcError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn host(message: impl Into) -> Self { + Self::new(code::HOST_ERROR, message) + } +} + +/// Any message on the control channel. Serde tries the shapes in order, +/// most demanding first: `Request` needs `id` and `method`, +/// `Notification` needs `method` without `id`, and `Response` is whatever +/// remains with an `id`. Unknown fields are ignored everywhere, so the +/// order is what keeps a request from reading as a response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Message { + Request(Request), + Notification(Notification), + Response(Response), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Request { + pub jsonrpc: Version, + pub id: u64, + pub method: String, + /// Omitted when there are none. + #[serde(default, skip_serializing_if = "Value::is_null")] + pub params: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Notification { + pub jsonrpc: Version, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Response { + pub jsonrpc: Version, + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The literal `"2.0"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Version; + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str("2.0") + } +} + +impl<'de> Deserialize<'de> for Version { + fn deserialize>(deserializer: D) -> Result { + let value = ::deserialize(deserializer)?; + if value == "2.0" { + Ok(Version) + } else { + Err(serde::de::Error::custom(format!( + "unsupported JSON-RPC version {value:?}" + ))) + } + } +} + +impl Request { + pub fn new(id: u64, method: impl Into, params: Value) -> Self { + Self { + jsonrpc: Version, + id, + method: method.into(), + params, + } + } +} + +impl Notification { + pub fn new(method: impl Into, params: Value) -> Self { + Self { + jsonrpc: Version, + method: method.into(), + params, + } + } +} + +impl Response { + pub fn ok(id: u64, result: Value) -> Self { + Self { + jsonrpc: Version, + id, + result: Some(result), + error: None, + } + } + + pub fn err(id: u64, error: RpcError) -> Self { + Self { + jsonrpc: Version, + id, + result: None, + error: Some(error), + } + } +} + +pub fn encode(message: &Message) -> Result, String> { + serde_json::to_vec(message).map_err(|error| format!("cannot encode message: {error}")) +} + +/// A successful response whose result is already JSON text, so a large +/// answer is not built as a tree and serialized again. +#[derive(Serialize)] +struct EncodedResponse<'a> { + jsonrpc: Version, + id: u64, + result: &'a serde_json::value::RawValue, +} + +/// Encode `Response::ok(id, result)` from the result's JSON text. +pub fn encode_result(id: u64, result: &serde_json::value::RawValue) -> Result, String> { + serde_json::to_vec(&EncodedResponse { + jsonrpc: Version, + id, + result, + }) + .map_err(|error| format!("cannot encode response: {error}")) +} + +pub fn decode(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|error| format!("cannot decode message: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messages_round_trip_and_classify() { + let request = Message::Request(Request::new(1, "session.list", serde_json::json!({}))); + let bytes = encode(&request).unwrap(); + assert_eq!(decode(&bytes).unwrap(), request); + + let response = Message::Response(Response::ok(1, serde_json::json!([]))); + assert_eq!(decode(&encode(&response).unwrap()).unwrap(), response); + + let failure = Message::Response(Response::err(2, RpcError::host("no"))); + assert_eq!(decode(&encode(&failure).unwrap()).unwrap(), failure); + + let note = Message::Notification(Notification::new("event", serde_json::json!({"seq": 1}))); + assert_eq!(decode(&encode(¬e).unwrap()).unwrap(), note); + + assert!(decode(br#"{"jsonrpc":"1.0","id":1,"method":"x"}"#).is_err()); + assert!(decode(b"not json").is_err()); + } + + #[test] + fn an_encoded_result_reads_back_as_the_same_response() { + let raw = serde_json::value::RawValue::from_string( + r#"{"items":[1,2],"hasMore":false}"#.to_string(), + ) + .unwrap(); + let bytes = encode_result(7, &raw).unwrap(); + assert_eq!( + decode(&bytes).unwrap(), + Message::Response(Response::ok( + 7, + serde_json::json!({"items": [1, 2], "hasMore": false}) + )) + ); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs new file mode 100644 index 000000000..8a0a9ccd0 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -0,0 +1,962 @@ +//! The host side: publishes a [`HostBackend`] to connections. +//! +//! One [`HostServer`] serves any number of connections. Each connection +//! subscribes to the host's events before it answers the handshake, so no +//! event is lost between the two; forwards every event with a per-connection +//! sequence; answers requests concurrently so a slow call never delays the +//! keepalive; and closes itself when its outbound queue overflows or the +//! peer goes quiet past the lease. +//! +//! Requests are dispatched by domain to one controller each. A controller +//! is a plain `match` over its domain's request enum; it never grows past +//! that domain. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use maple_agent::agent::{AgentImageUpload, AgentSendMessageRequest, AgentSessionDetail}; +use maple_agent::host::{HostBackend, HostBootstrap}; +use serde::Serialize; +use serde_json::Value; +use serde_json::value::RawValue; +use tokio::sync::{Mutex, Notify}; +use tokio_util::sync::CancellationToken; + +use crate::carrier::Carrier; +use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; +use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; +use crate::rpc::{self, Message, Request as RpcRequest, Response, RpcError, code}; +use crate::streams::{ + ATTACHMENT_PURPOSE, Opener, StreamOpen, StreamSenders, close_frame, decode_credit, +}; +use crate::uploads::Uploads; +use crate::wire::{ + AttachmentHandle, BootstrapSnapshot, ClientHello, EventEnvelope, Features, HostHello, HostInfo, + HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, Request, + RunRequest, SessionRequest, SessionSnapshot, UPLOAD_STREAMS_FEATURE, UploadRef, decode_request, + features, has_feature, version_label, +}; + +/// What the host tells clients about itself in the handshake. +#[derive(Debug, Clone)] +pub struct HostIdentity { + pub app_version: String, + /// The git revision this host was built from, when the build knew it. + pub build: Option, + pub pcr_environment: String, +} + +/// Called once a client's hello is accepted, with what the client claims +/// about itself. The listener uses it to name the paired device. +pub type ClientHelloHook = Arc; + +#[derive(Clone)] +pub struct HostServerConfig { + /// Bytes one connection may have queued before it is closed. + pub max_outbound_bytes: usize, + /// A connection with no inbound frame for this long is closed. + pub lease: Duration, + /// How often the lease is checked. + pub lease_check: Duration, + /// Most timeline items in one page. + pub timeline_page_items: usize, + /// Approximate serialized bytes in one page; a page stops before + /// the item that would cross it (at least one item always fits). + pub timeline_page_bytes: usize, + pub on_client_hello: Option, +} + +impl Default for HostServerConfig { + fn default() -> Self { + Self { + max_outbound_bytes: DEFAULT_MAX_OUTBOUND_BYTES, + lease: Duration::from_secs(45), + lease_check: Duration::from_secs(10), + timeline_page_items: 200, + timeline_page_bytes: 1024 * 1024, + on_client_hello: None, + } + } +} + +/// Loaded tasks one connection keeps for paging. Loading a ninth drops +/// the one paged least recently. +pub const MAX_KEPT_SNAPSHOTS: usize = 8; + +/// Project roots one connection may watch at once. +pub const MAX_WATCHED_ROOTS: usize = 64; + +/// How long a closing connection waits for its queued frames (a refusal, +/// an error answer) to reach the peer before the writer is abandoned. +const CLOSE_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); + +pub struct HostServer { + host: Arc, + info: HostInfo, + identity: HostIdentity, + config: HostServerConfig, + /// Minted once per process; every connection's sequences are scoped + /// to it. + generation: String, +} + +impl HostServer { + pub fn new( + host: Arc, + info: HostInfo, + identity: HostIdentity, + config: HostServerConfig, + ) -> Arc { + Arc::new(Self { + host, + info, + identity, + config, + generation: uuid::Uuid::new_v4().to_string(), + }) + } + + /// Serve one connection until it ends, with no identity check on the + /// hello. For carriers that authenticated nothing: tests and loopback. + pub async fn serve(self: Arc, carrier: Carrier) -> Result<(), String> { + self.serve_with_peer(carrier, None, CancellationToken::new()) + .await + } + + /// Serve one connection whose transport authenticated the peer as + /// `peer` (the device's static public key). The hello must claim the + /// same key, so the identity in the protocol is the identity the + /// handshake proved. Cancelling `cancel` ends the connection. + pub async fn serve_with_peer( + self: Arc, + carrier: Carrier, + peer: Option, + cancel: CancellationToken, + ) -> Result<(), String> { + let Carrier { + mut sink, + mut stream, + } = carrier; + let (out, mut queue) = outbound::channel(self.config.max_outbound_bytes); + let connection = Arc::new(Connection { + server: Arc::clone(&self), + out, + senders: StreamSenders::new(Opener::Host), + uploads: Uploads::default(), + client_features: OnceLock::new(), + snapshots: Mutex::new(Vec::new()), + watched_roots: Mutex::new(HashMap::new()), + ready: AtomicBool::new(false), + ready_notify: Notify::new(), + last_activity: std::sync::Mutex::new(Instant::now()), + peer, + closed: cancel, + close_reason: std::sync::Mutex::new(None), + }); + + // Subscribe before the handshake so nothing is missed between the + // two; the forwarder holds events until the client is ready. An + // overflow closes the connection right here. + let mut events = self.host.subscribe(); + let forwarder = { + let connection = Arc::clone(&connection); + tokio::spawn(async move { + connection.ready_notify.notified().await; + let mut seq: u64 = 0; + while let Some(event) = events.recv().await { + seq += 1; + let envelope = EventEnvelope { seq, event }; + if let Err(error) = connection.notify(crate::wire::EVENT_METHOD, &envelope) { + connection.close(&error); + break; + } + } + }) + }; + + // The writer owns the carrier's sink and the queue's draining end + // and nothing else, so it cannot keep the connection alive. It + // ends when the connection closes, after writing what was queued. + let mut writer = { + let closed = connection.closed.clone(); + tokio::spawn(async move { + loop { + let frame = tokio::select! { + frame = queue.recv() => frame, + _ = closed.cancelled() => { + while let Some(frame) = queue.try_recv() { + if sink.send(frame).await.is_err() { + break; + } + } + None + } + }; + let Some(frame) = frame else { break }; + if sink.send(frame).await.is_err() { + break; + } + } + sink.close().await; + }) + }; + + let lease = { + let connection = Arc::clone(&connection); + let lease = self.config.lease; + let check = self.config.lease_check; + tokio::spawn(async move { + loop { + tokio::time::sleep(check).await; + if connection.idle_for() > lease { + connection.close("lease expired"); + return; + } + } + }) + }; + + let reason = loop { + let frame = tokio::select! { + frame = stream.recv() => frame, + _ = connection.closed.cancelled() => None, + }; + let Some(frame) = frame else { + connection.close("peer closed"); + break connection + .close_reason() + .unwrap_or_else(|| "peer closed".to_string()); + }; + connection.touch(); + if let Err(error) = connection.on_frame(frame) { + connection.close(&error); + break error; + } + }; + forwarder.abort(); + lease.abort(); + // Let queued frames (a refusal, an error answer) reach the peer; a + // peer that stopped reading does not get to hold the writer. + if tokio::time::timeout(CLOSE_FLUSH_TIMEOUT, &mut writer) + .await + .is_err() + { + writer.abort(); + } + connection.release_watches().await; + log::debug!("host connection ended: {reason}"); + Ok(()) + } +} + +/// One client connection. +struct Connection { + server: Arc, + out: Outbound, + /// Streams this host opens: attachment reads. + senders: StreamSenders, + /// Streams the client opens: images ahead of `run.send`. Dropped + /// with the connection. + uploads: Uploads, + /// What the client's hello advertised. + client_features: OnceLock, + /// Snapshots the client pages through, least recently paged first. + /// Replaced by the next load of the same task; at most + /// [`MAX_KEPT_SNAPSHOTS`]. + snapshots: Mutex)>>, + /// Project roots this connection asked the host to watch, with how + /// many times each, so teardown can balance every watch. + watched_roots: Mutex>, + ready: AtomicBool, + ready_notify: Notify, + last_activity: std::sync::Mutex, + /// The device key the transport proved, when it proved one. + peer: Option, + closed: CancellationToken, + close_reason: std::sync::Mutex>, +} + +impl Connection { + fn touch(&self) { + *self + .last_activity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Instant::now(); + } + + fn idle_for(&self) -> Duration { + self.last_activity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed() + } + + /// End the connection. The first reason given is the one kept. + fn close(&self, reason: &str) { + let mut close_reason = self + .close_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if close_reason.is_none() { + log::debug!("closing host connection: {reason}"); + *close_reason = Some(reason.to_string()); + self.closed.cancel(); + } + } + + fn client_has(&self, feature: &str) -> bool { + self.client_features + .get() + .is_some_and(|features| has_feature(features, feature)) + } + + fn close_reason(&self) -> Option { + self.close_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// The connection is gone: take back every watch it placed. + async fn release_watches(&self) { + let watched = std::mem::take(&mut *self.watched_roots.lock().await); + for (root, count) in watched { + for _ in 0..count { + if let Err(error) = self.host().unwatch_project_root(root.clone()).await { + log::debug!("cannot unwatch {root} at teardown: {error}"); + } + } + } + } + + fn notify(&self, method: &str, params: &T) -> Result<(), String> { + let params = serde_json::to_value(params).map_err(|error| error.to_string())?; + let message = Message::Notification(rpc::Notification::new(method, params)); + self.send_message(&message) + } + + fn send_message(&self, message: &Message) -> Result<(), String> { + let bytes = rpc::encode(message)?; + self.out.try_send(Frame::control(bytes)) + } + + fn respond(&self, response: Response) { + if let Err(error) = self.send_message(&Message::Response(response)) { + self.close(&error); + } + } + + /// Answer a request with JSON that is already encoded. + fn respond_ok(&self, id: u64, answer: &RawValue) { + if let Err(error) = rpc::encode_result(id, answer) + .and_then(|bytes| self.out.try_send(Frame::control(bytes))) + { + self.close(&error); + } + } + + /// Route one inbound frame. Requests are answered on their own task. + fn on_frame(self: &Arc, frame: Frame) -> Result<(), String> { + if frame.channel != CONTROL_CHANNEL { + return self.on_stream_frame(frame); + } + let message = rpc::decode(&frame.payload)?; + match message { + Message::Request(request) => { + let connection = Arc::clone(self); + tokio::spawn(async move { connection.handle(request).await }); + } + Message::Notification(notification) => { + log::debug!("ignoring notification {}", notification.method); + } + Message::Response(_) => { + log::debug!("ignoring a response from the client"); + } + } + Ok(()) + } + + /// Route a frame on a stream channel by which side opened it. + fn on_stream_frame(&self, frame: Frame) -> Result<(), String> { + match Opener::of_channel(frame.channel) { + // A stream this host is sending on: the client's flow control + // and its acknowledgement or refusal. + Opener::Host => match frame.kind { + FrameKind::Credit => { + if let Some(credit) = decode_credit(&frame.payload) { + self.senders.credit(frame.channel, credit); + } + } + FrameKind::Close => self.senders.on_close(frame.channel, &frame.payload), + FrameKind::Open | FrameKind::Data => {} + }, + // A stream the client is sending on: an upload. Before the + // handshake, or from a client that did not advertise uploads, + // an open is refused and anything else is dropped. + Opener::Client => { + let reply = if self.client_has(UPLOAD_STREAMS_FEATURE) { + self.uploads.on_frame(&frame) + } else if frame.kind == FrameKind::Open { + Some(close_frame( + frame.channel, + Some("uploads need a handshake that advertises uploadStreams"), + )) + } else { + None + }; + if let Some(reply) = reply { + self.out.try_send(reply)?; + } + } + } + Ok(()) + } + + async fn handle(self: Arc, request: RpcRequest) { + let id = request.id; + let decoded = match decode_request(&request.method, request.params) { + Ok(decoded) => decoded, + Err(crate::wire::DecodeError::UnknownMethod(method)) => { + self.respond(Response::err( + id, + RpcError::new(code::METHOD_NOT_FOUND, format!("unknown method {method}")), + )); + return; + } + Err(crate::wire::DecodeError::InvalidParams(message)) => { + self.respond(Response::err( + id, + RpcError::new(code::INVALID_PARAMS, message), + )); + return; + } + }; + if !self.ready.load(Ordering::Acquire) { + match decoded { + Request::Host(HostRequest::Hello(hello)) => self.handle_hello(id, hello), + _ => self.respond(Response::err( + id, + RpcError::new(code::NOT_READY, "send host.hello first"), + )), + } + return; + } + // A hello after the handshake is an ordinary request, and the host + // controller refuses it: the hook and the ready state ran once. + let result = match decoded { + Request::Host(request) => self.host_controller(request).await, + Request::Project(request) => self.project_controller(request).await, + Request::Session(request) => self.session_controller(id, request).await, + Request::Run(request) => self.run_controller(request).await, + Request::Model(request) => self.model_controller(request).await, + Request::Integration(request) => self.integration_controller(request).await, + }; + match result { + Ok(answer) => self.respond_ok(id, &answer), + Err(error) => self.respond(Response::err(id, error)), + } + } + + fn handle_hello(&self, id: u64, mut hello: ClientHello) { + // The name is what the client claims; keep it short and printable + // before it reaches a log or the device list. + hello.device.name = crate::devices::clean_device_name(&hello.device.name); + let identity = &self.server.identity; + let refusal = if hello.protocol != PROTOCOL_VERSION { + Some(format!( + "protocol {} is not supported; this host speaks {PROTOCOL_VERSION}. Update the client or the host.", + hello.protocol + )) + } else if hello.pcr_environment != identity.pcr_environment { + Some(format!( + "the client is built for the {} environment and this host for {}", + hello.pcr_environment, identity.pcr_environment + )) + } else if self + .peer + .as_ref() + .is_some_and(|peer| peer != &hello.device.public_key) + { + Some("the hello names a different device than the one that connected".to_string()) + } else { + None + }; + if let Some(message) = refusal { + self.respond(Response::err( + id, + RpcError::new(code::HANDSHAKE_REFUSED, message), + )); + self.close("handshake refused"); + return; + } + log::info!( + "client {} ({}) connected running maple-agent {}", + hello.device.name, + hello.device.public_key, + version_label(&hello.app_version, hello.build.as_deref()) + ); + let answer = HostHello { + protocol: PROTOCOL_VERSION, + app_version: identity.app_version.clone(), + build: identity.build.clone(), + pcr_environment: identity.pcr_environment.clone(), + generation: self.server.generation.clone(), + seq: 0, + features: features(), + host: self.server.info.clone(), + }; + let answer = match encode_answer(&answer) { + Ok(answer) => answer, + Err(error) => { + self.respond(Response::err(id, error)); + return; + } + }; + // Two hellos in flight at once: the first to get here wins. + if self + .ready + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + self.respond(Response::err( + id, + RpcError::new(code::INVALID_REQUEST, "hello was already sent"), + )); + return; + } + let _ = self.client_features.set(hello.features.clone()); + self.respond_ok(id, &answer); + self.ready_notify.notify_one(); + if let Some(hook) = &self.server.config.on_client_hello { + hook(&hello); + } + } + + fn host(&self) -> &Arc { + &self.server.host + } + + /// Encode a controller result once, mapping host errors to RPC errors. + fn ok(value: Result) -> Result { + let value = value.map_err(RpcError::host)?; + encode_answer(&value) + } + + async fn host_controller(&self, request: HostRequest) -> Result { + let host = self.host(); + match request { + HostRequest::Hello(_) => Err(RpcError::new( + code::INVALID_REQUEST, + "hello was already sent", + )), + HostRequest::Ping => Self::ok(Ok(serde_json::Map::::new())), + HostRequest::Bootstrap => { + let bootstrap = host.bootstrap().await.map_err(RpcError::host)?; + Self::ok(Ok(self.snapshot_bootstrap(bootstrap).await)) + } + HostRequest::StartRuntime { request } => Self::ok(host.start_runtime(request).await), + HostRequest::StopRuntime => Self::ok(host.stop_runtime().await), + HostRequest::SessionDefaults => Self::ok(host.session_defaults().await), + HostRequest::SetSessionDefaults { defaults } => { + Self::ok(host.set_session_defaults(defaults).await) + } + HostRequest::SaveDefaultModel { model } => { + Self::ok(host.save_default_model(model).await) + } + HostRequest::UsageSummary => Self::ok(host.usage_summary().await), + HostRequest::ContextUsage { session_id, model } => { + Self::ok(host.context_usage(session_id, model).await) + } + HostRequest::ToolSummaries { session_id } => { + Self::ok(host.tool_summaries(session_id).await) + } + HostRequest::StoreToolSummary { + session_id, + item_id, + summary, + } => Self::ok(host.store_tool_summary(session_id, item_id, summary).await), + } + } + + async fn project_controller(&self, request: ProjectRequest) -> Result { + let host = self.host(); + match request { + ProjectRequest::RecentRoots => Self::ok(host.recent_project_roots().await), + ProjectRequest::SelectRoot { path } => Self::ok(host.select_project_root(path).await), + ProjectRequest::RemoveRoot { path, fallback } => { + Self::ok(host.remove_project_root(path, fallback).await) + } + ProjectRequest::SuggestDirectories { query } => { + Self::ok(host.suggest_directories(query).await) + } + ProjectRequest::Watch { path } => { + let mut watched = self.watched_roots.lock().await; + if !watched.contains_key(&path) && watched.len() >= MAX_WATCHED_ROOTS { + return Err(RpcError::new( + code::INVALID_REQUEST, + format!("a connection may watch at most {MAX_WATCHED_ROOTS} project roots"), + )); + } + host.watch_project_root(path.clone()) + .await + .map_err(RpcError::host)?; + *watched.entry(path).or_default() += 1; + Self::ok(Ok(())) + } + ProjectRequest::Unwatch { path } => { + let mut watched = self.watched_roots.lock().await; + match watched.get_mut(&path) { + Some(count) if *count > 1 => *count -= 1, + Some(_) => { + watched.remove(&path); + } + // Never watched here: nothing to balance. + None => return Self::ok(Ok(())), + } + Self::ok(host.unwatch_project_root(path).await) + } + ProjectRequest::Trust { path } => Self::ok(host.project_trust(path).await), + ProjectRequest::SetTrust { path, trusted } => { + Self::ok(host.set_project_trust(path, trusted).await) + } + } + } + + async fn session_controller( + self: &Arc, + request_id: u64, + request: SessionRequest, + ) -> Result { + let host = self.host(); + match request { + SessionRequest::List { project_root } => { + Self::ok(host.list_sessions(project_root).await) + } + SessionRequest::Create { request } => Self::ok(host.create_session(request).await), + SessionRequest::Load { session_id } => { + let detail = host + .load_session(session_id) + .await + .map_err(RpcError::host)?; + Self::ok(Ok(self.snapshot_session(detail).await)) + } + SessionRequest::Timeline { + session_id, + offset, + limit, + } => Self::ok(self.timeline_page(session_id, offset, limit).await), + SessionRequest::Rename { session_id, title } => { + Self::ok(host.rename_session(session_id, title).await) + } + SessionRequest::SetState { session_id, state } => { + Self::ok(host.set_session_state(session_id, state).await) + } + SessionRequest::Delete { session_id } => { + Self::ok(host.delete_session(session_id).await) + } + SessionRequest::Compact { session_id } => { + Self::ok(host.compact_session(session_id).await) + } + SessionRequest::Subagents { session_id } => { + Self::ok(host.session_subagents(session_id).await) + } + SessionRequest::CancelExternalAgent { + session_id, + agent_id, + } => Self::ok(host.cancel_external_agent(session_id, agent_id).await), + SessionRequest::SetPermissionMode { session_id, mode } => { + Self::ok(host.set_permission_mode(session_id, mode).await) + } + SessionRequest::SetWebEnabled { + session_id, + enabled, + } => Self::ok(host.set_session_web_enabled(session_id, enabled).await), + SessionRequest::ReadAttachment { + session_id, + attachment_id, + } => { + let bytes = host + .read_image_attachment(session_id, attachment_id) + .await + .map_err(RpcError::host)?; + let mut sender = self + .senders + .open( + &self.out, + StreamOpen { + purpose: ATTACHMENT_PURPOSE.to_string(), + request_id: Some(request_id), + upload_id: None, + mime: None, + len: Some(bytes.len() as u64), + }, + ) + .map_err(RpcError::host)?; + let handle = AttachmentHandle { + stream: sender.channel(), + len: bytes.len() as u64, + }; + // The bytes follow the answer; the receiver pairs them + // through the request id in the open frame. + let connection = Arc::clone(self); + tokio::spawn(async move { + if let Err(error) = sender.send_all(&bytes).await { + log::debug!("attachment stream ended early: {error}"); + if connection.out.overflowed() { + connection.close(&error); + } + } + }); + Self::ok(Ok(handle)) + } + } + } + + async fn run_controller(&self, request: RunRequest) -> Result { + let host = self.host(); + match request { + RunRequest::Send { request, uploads } => { + let request = self.resolve_uploads(request, uploads)?; + Self::ok(host.send_message(request).await) + } + RunRequest::Cancel { run_id } => Self::ok(host.cancel_run(run_id).await), + RunRequest::CancelQueued { + session_id, + queue_id, + } => Self::ok(host.cancel_queued_message(session_id, queue_id).await), + RunRequest::BeginQueuedEdit { + session_id, + queue_id, + } => Self::ok(host.begin_queued_message_edit(session_id, queue_id).await), + RunRequest::EndQueuedEdit { + session_id, + queue_id, + } => Self::ok(host.end_queued_message_edit(session_id, queue_id).await), + RunRequest::AnswerQuestion { request_id, answer } => { + Self::ok(host.answer_question(request_id, answer).await) + } + RunRequest::PermissionRespond { + session_id, + request_id, + allow, + } => Self::ok(host.permission_respond(session_id, request_id, allow).await), + RunRequest::AskSideQuestion { + session_id, + request_id, + prior, + question, + } => Self::ok( + host.ask_side_question(session_id, request_id, prior, question) + .await, + ), + RunRequest::SummarizeToolCall { + session_id, + tool_name, + input, + output_text, + } => Self::ok( + host.summarize_tool_call(session_id, tool_name, input, output_text) + .await, + ), + RunRequest::SummarizeThinking { + session_id, + thinking_text, + } => Self::ok(host.summarize_thinking(session_id, thinking_text).await), + } + } + + /// Replace the upload ids a `run.send` names with the images the + /// client streamed, consuming them. Images inline in the request are + /// refused: they would be capped by the control frame limit. + fn resolve_uploads( + &self, + mut request: AgentSendMessageRequest, + uploads: Vec, + ) -> Result { + if !request.attachments.is_empty() { + return Err(RpcError::new( + code::INVALID_PARAMS, + "this host takes images as upload streams, not inline in run.send; update the client", + )); + } + if uploads.is_empty() { + return Ok(request); + } + if !self.client_has(UPLOAD_STREAMS_FEATURE) { + return Err(RpcError::new( + code::INVALID_PARAMS, + "run.send names uploads but the client did not advertise uploadStreams", + )); + } + let ids: Vec = uploads + .iter() + .map(|upload| upload.upload_id.clone()) + .collect(); + let images = self + .uploads + .take_all(&ids) + .map_err(|reason| RpcError::new(code::INVALID_PARAMS, reason))?; + request.attachments = uploads + .into_iter() + .zip(images) + .map(|(upload, image)| AgentImageUpload { + name: upload.name, + data_url: image.data_url(), + }) + .collect(); + Ok(request) + } + + async fn model_controller(&self, request: ModelRequest) -> Result { + let host = self.host(); + match request { + ModelRequest::List => Self::ok(host.available_model_ids().await), + ModelRequest::SupportsVision { model } => { + Self::ok(host.model_supports_vision(model).await) + } + ModelRequest::SlashCommands { working_dir } => { + Self::ok(host.list_slash_commands(working_dir).await) + } + ModelRequest::ResolveSlashCommand { + working_dir, + command, + args, + } => Self::ok(host.resolve_slash_command(working_dir, command, args).await), + } + } + + async fn integration_controller( + &self, + request: IntegrationRequest, + ) -> Result { + let host = self.host(); + match request { + IntegrationRequest::ListSessionMcp { session_id } => { + Self::ok(host.list_session_mcp_servers(session_id).await) + } + IntegrationRequest::SetSessionMcp { + session_id, + name, + kind, + enabled, + } => Self::ok( + host.set_session_mcp_server_enabled(session_id, name, kind, enabled) + .await, + ), + IntegrationRequest::ListMcp => Self::ok(host.list_mcp_servers().await), + IntegrationRequest::SaveMcp { servers } => { + Self::ok(host.save_mcp_servers(servers).await) + } + IntegrationRequest::List => Self::ok(host.list_integrations().await), + IntegrationRequest::SetEnabled { id, enabled } => { + Self::ok(host.set_integration_enabled(id, enabled).await) + } + } + } + + /// Keep a loaded task for paging and answer with its timeline stripped. + async fn snapshot_session(&self, detail: AgentSessionDetail) -> SessionSnapshot { + let timeline_len = detail.timeline.len(); + let detail = Arc::new(detail); + self.keep_snapshot(&detail.session.id, Arc::clone(&detail)) + .await; + let mut stripped = (*detail).clone(); + stripped.timeline = Vec::new(); + SessionSnapshot { + detail: stripped, + timeline_len, + } + } + + /// Remember `detail` as the most recently used snapshot, dropping the + /// least recently used one past [`MAX_KEPT_SNAPSHOTS`]. + async fn keep_snapshot(&self, session_id: &str, detail: Arc) { + let mut snapshots = self.snapshots.lock().await; + snapshots.retain(|(id, _)| id != session_id); + snapshots.push((session_id.to_string(), detail)); + if snapshots.len() > MAX_KEPT_SNAPSHOTS { + snapshots.remove(0); + } + } + + /// The kept snapshot of `session_id`, marked most recently used. + async fn kept_snapshot(&self, session_id: &str) -> Option> { + let mut snapshots = self.snapshots.lock().await; + let index = snapshots.iter().position(|(id, _)| id == session_id)?; + let entry = snapshots.remove(index); + let detail = Arc::clone(&entry.1); + snapshots.push(entry); + Some(detail) + } + + async fn snapshot_bootstrap(&self, mut bootstrap: HostBootstrap) -> BootstrapSnapshot { + let mut latest_timeline_len = 0; + if let Some(latest) = bootstrap.latest.take() { + let snapshot = self.snapshot_session(latest).await; + latest_timeline_len = snapshot.timeline_len; + bootstrap.latest = Some(snapshot.detail); + } + BootstrapSnapshot { + bootstrap, + latest_timeline_len, + } + } + + /// One page of a kept snapshot, bounded by item count and bytes. A + /// task that was never loaded on this connection is loaded first. + /// Each item is serialized once, to measure it, and that text is what + /// the answer carries. + async fn timeline_page( + &self, + session_id: String, + offset: usize, + limit: usize, + ) -> Result { + let detail = match self.kept_snapshot(&session_id).await { + Some(detail) => detail, + None => { + let detail = self.host().load_session(session_id.clone()).await?; + let detail = Arc::new(detail); + self.keep_snapshot(&session_id, Arc::clone(&detail)).await; + detail + } + }; + let config = &self.server.config; + let limit = limit.clamp(1, config.timeline_page_items); + let mut items: Vec> = Vec::new(); + let mut bytes = 0usize; + for item in detail.timeline.iter().skip(offset) { + let json = serde_json::to_string(item).map_err(|error| error.to_string())?; + if !items.is_empty() + && (items.len() >= limit || bytes + json.len() > config.timeline_page_bytes) + { + break; + } + bytes += json.len(); + items.push(RawValue::from_string(json).map_err(|error| error.to_string())?); + } + let has_more = offset + items.len() < detail.timeline.len(); + Ok(EncodedTimelinePage { items, has_more }) + } +} + +/// A controller's answer: the result's JSON, encoded once. +type Answer = Box; + +fn encode_answer(value: &T) -> Result { + serde_json::to_string(value) + .and_then(RawValue::from_string) + .map_err(|error| RpcError::host(error.to_string())) +} + +/// [`crate::wire::TimelinePage`] with its items already encoded; the same +/// JSON on the wire. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EncodedTimelinePage { + items: Vec>, + has_more: bool, +} diff --git a/apps/maple-agent/crates/maple-remote/src/streams.rs b/apps/maple-agent/crates/maple-remote/src/streams.rs new file mode 100644 index 000000000..71c11de83 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/streams.rs @@ -0,0 +1,620 @@ +//! Binary streams on channels 1 and up, with credit-based flow control. +//! +//! Either side opens a stream with an `Open` frame, sends `Data` frames +//! while it holds credit, and ends with `Close`. Clients open odd channels +//! and hosts even ones, so neither side's numbering collides with the +//! other's. The receiver starts the sender with [`INITIAL_CREDIT`] frames +//! and grants more as it consumes, so one slow transfer can never fill the +//! connection's outbound queue. The receiver answers with its own `Close` +//! once it has the bytes, or earlier to refuse them, so a sender that +//! needs an acknowledgement can wait for one. +//! +//! The host sends attachments this way (`session.read_attachment`) and +//! the client sends uploads ahead of `run.send` (see [`crate::uploads`]). +//! A PTY would use the same frames with its own `purpose`. +//! +//! This module holds what both directions share: the sender, and a +//! collector that gathers one stream's bytes under a limit. How a stream +//! is paired with the request or upload it belongs to is each side's own. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::frame::{Frame, FrameKind, MAX_STREAM_FRAME_BYTES}; +use crate::outbound::Outbound; + +/// Frames a sender may have in flight when a stream opens. +pub const INITIAL_CREDIT: u32 = 16; +/// The receiver grants this many more frames each time it has consumed +/// this many. +pub const CREDIT_REFILL: u32 = 8; + +/// The host's own limit per image, which bounds a stream in either +/// direction. +pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; + +/// `purpose` of a stream the host opens to answer `session.read_attachment`. +pub const ATTACHMENT_PURPOSE: &str = "attachment"; +/// `purpose` of a stream the client opens to upload an image ahead of +/// `run.send`. +pub const UPLOAD_PURPOSE: &str = "upload"; + +/// Payload of an `Open` frame. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamOpen { + /// What the bytes are: [`ATTACHMENT_PURPOSE`] or [`UPLOAD_PURPOSE`]. + pub purpose: String, + /// For an attachment: the JSON-RPC request this stream answers, so + /// the receiver can pair the bytes with the response that names the + /// channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + /// For an upload: the id the client minted, which its `run.send` + /// names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upload_id: Option, + /// For an upload: the media type of the bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime: Option, + /// Total bytes, when known up front. The receiver holds the sender to + /// it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub len: Option, +} + +/// Payload of a `Close` frame that ends or refuses a stream early. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamClose { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Which side opened a channel. Clients take the odd channels and hosts +/// the even ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Opener { + Client, + Host, +} + +impl Opener { + pub fn of_channel(channel: u16) -> Self { + if channel % 2 == 1 { + Self::Client + } else { + Self::Host + } + } + + fn first_channel(self) -> u16 { + match self { + Self::Client => 1, + Self::Host => 2, + } + } +} + +fn credit_frame(channel: u16, credit: u32) -> Frame { + Frame { + channel, + kind: FrameKind::Credit, + payload: Bytes::copy_from_slice(&credit.to_be_bytes()), + } +} + +pub fn decode_credit(payload: &[u8]) -> Option { + let bytes: [u8; 4] = payload.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) +} + +/// A `Close` frame: empty to end or acknowledge a stream, or naming the +/// error that ends it early. +pub fn close_frame(channel: u16, error: Option<&str>) -> Frame { + let payload = match error { + Some(error) => serde_json::to_vec(&StreamClose { + error: Some(error.to_string()), + }) + .unwrap_or_default(), + None => Vec::new(), + }; + Frame { + channel, + kind: FrameKind::Close, + payload: Bytes::from(payload), + } +} + +/// The error in a `Close` payload, if it names one. +fn close_error(payload: &[u8]) -> Option { + if payload.is_empty() { + return None; + } + serde_json::from_slice::(payload) + .ok() + .and_then(|close| close.error) +} + +// ---- Sending side ------------------------------------------------------------- + +/// What the peer tells a sender about its stream. +enum Signal { + Credit(u32), + /// The peer closed the stream: acknowledged it, or refused it with + /// the error. + Closed(Result<(), String>), +} + +type Signals = Arc>>>; + +/// One open stream the local side is sending on. +pub struct StreamSender { + channel: u16, + out: Outbound, + signals: mpsc::UnboundedReceiver, + registry: Signals, + available: u32, + /// The `Close` frame went out; dropping the sender sends nothing. + finished: bool, +} + +impl StreamSender { + pub fn channel(&self) -> u16 { + self.channel + } + + /// Send every byte in frames of at most [`MAX_STREAM_FRAME_BYTES`], + /// waiting for credit between frames, then close the stream. Fails + /// when the peer closes the stream first. + pub async fn send_all(&mut self, bytes: &[u8]) -> Result<(), String> { + for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { + while self.available == 0 { + match self.signals.recv().await { + Some(Signal::Credit(credit)) => self.available += credit, + Some(Signal::Closed(Err(error))) => return Err(error), + Some(Signal::Closed(Ok(()))) => { + return Err("the receiver closed the stream early".to_string()); + } + None => return Err("the connection ended".to_string()), + } + } + self.out.try_send(Frame { + channel: self.channel, + kind: FrameKind::Data, + payload: Bytes::copy_from_slice(chunk), + })?; + self.available -= 1; + } + self.out.try_send(close_frame(self.channel, None))?; + self.finished = true; + Ok(()) + } + + /// Wait for the peer's `Close`: its acknowledgement that it took the + /// bytes, or the error it refused them with. + pub async fn wait_for_ack(mut self) -> Result<(), String> { + loop { + match self.signals.recv().await { + Some(Signal::Credit(_)) => continue, + Some(Signal::Closed(outcome)) => return outcome, + None => return Err("the connection ended".to_string()), + } + } + } +} + +impl Drop for StreamSender { + /// A sender dropped mid-stream tells the peer to forget the bytes, + /// so a timed-out transfer does not sit in the peer's limits until + /// the connection ends. + fn drop(&mut self) { + if !self.finished { + let _ = self + .out + .try_send(close_frame(self.channel, Some("the sender gave up"))); + } + self.registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.channel); + } +} + +/// The streams one connection is sending, keyed by channel, so the peer's +/// `Credit` and `Close` frames reach the right sender. +pub struct StreamSenders { + opener: Opener, + next_channel: AtomicU16, + signals: Signals, +} + +impl StreamSenders { + pub fn new(opener: Opener) -> Self { + Self { + opener, + next_channel: AtomicU16::new(opener.first_channel()), + signals: Arc::default(), + } + } + + /// Open a stream: queues the `Open` frame and returns the sender, which + /// starts with [`INITIAL_CREDIT`]. + pub fn open(&self, out: &Outbound, open: StreamOpen) -> Result { + let payload = serde_json::to_vec(&open).map_err(|error| error.to_string())?; + let channel = self.allocate_channel(); + let (signal_tx, signal_rx) = mpsc::unbounded_channel(); + self.signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(channel, signal_tx); + if let Err(error) = out.try_send(Frame { + channel, + kind: FrameKind::Open, + payload: Bytes::from(payload), + }) { + self.signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&channel); + return Err(error); + } + Ok(StreamSender { + channel, + out: out.clone(), + signals: signal_rx, + registry: Arc::clone(&self.signals), + available: INITIAL_CREDIT, + finished: false, + }) + } + + fn allocate_channel(&self) -> u16 { + loop { + // Stepping by two keeps this side's parity; wrapping past + // u16::MAX keeps it too, since 65536 is even. + let channel = self.next_channel.fetch_add(2, Ordering::Relaxed); + if channel != 0 { + return channel; + } + } + } + + /// This side's parity. + pub fn opener(&self) -> Opener { + self.opener + } + + fn signal(&self, channel: u16, signal: Signal) { + let mut signals = self + .signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(sender) = signals.get(&channel) + && sender.send(signal).is_err() + { + signals.remove(&channel); + } + } + + /// The peer granted `credit` more frames on `channel`. + pub fn credit(&self, channel: u16, credit: u32) { + self.signal(channel, Signal::Credit(credit)); + } + + /// The peer closed `channel`: acknowledged the bytes, or refused them. + pub fn on_close(&self, channel: u16, payload: &[u8]) { + let outcome = match close_error(payload) { + Some(error) => Err(error), + None => Ok(()), + }; + self.signal(channel, Signal::Closed(outcome)); + } + + /// The connection ended: every sender still waiting fails. + pub fn fail_all(&self, reason: &str) { + let signals = std::mem::take( + &mut *self + .signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, sender) in signals { + let _ = sender.send(Signal::Closed(Err(reason.to_string()))); + } + } +} + +// ---- Receiving side ----------------------------------------------------------- + +struct Collector { + key: K, + bytes: Vec, + declared_len: Option, + consumed_since_credit: u32, +} + +/// The collected bytes of one stream, or why it ended early. +pub type StreamResult = Result, String>; + +/// The streams one connection is receiving, keyed by channel. Bytes are +/// collected whole under a byte limit and the length the sender declared; +/// a stream that crosses either is dropped and its key returned, so the +/// side can tell the sender. `K` names what a stream belongs to: the +/// request it answers on the client, the upload id on the host. +pub struct StreamReceivers { + open: Mutex>>, + max_bytes: usize, +} + +impl StreamReceivers { + pub fn new(max_bytes: usize) -> Self { + Self { + open: Mutex::new(HashMap::new()), + max_bytes, + } + } + + /// Start collecting `channel` under `key`. Refuses a declared length + /// over the limit and a channel already collecting. + pub fn accept(&self, channel: u16, key: K, len: Option) -> Result<(), String> { + if let Some(len) = len + && len > self.max_bytes as u64 + { + return Err(format!( + "{len} bytes exceeds the {} byte limit", + self.max_bytes + )); + } + let mut open = self + .open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if open.contains_key(&channel) { + return Err(format!("channel {channel} is already open")); + } + open.insert( + channel, + Collector { + key, + bytes: Vec::with_capacity(len.unwrap_or(0) as usize), + declared_len: len, + consumed_since_credit: 0, + }, + ); + Ok(()) + } + + /// A `Data` frame arrived. Returns a credit frame to send back when the + /// sender has earned more, or the key and reason of a stream that + /// crossed its limit, which is dropped. + pub fn on_data(&self, channel: u16, payload: &[u8]) -> Result, (K, String)> { + let mut open = self + .open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(collector) = open.get_mut(&channel) else { + return Ok(None); + }; + let total = collector.bytes.len() + payload.len(); + let over = if collector + .declared_len + .is_some_and(|declared| total as u64 > declared) + { + Some(format!( + "more bytes than the {} declared", + collector.declared_len.unwrap_or(0) + )) + } else if total > self.max_bytes { + Some(format!("exceeds the {} byte limit", self.max_bytes)) + } else { + None + }; + if let Some(reason) = over { + let collector = open.remove(&channel).expect("just found"); + return Err((collector.key, reason)); + } + collector.bytes.extend_from_slice(payload); + collector.consumed_since_credit += 1; + if collector.consumed_since_credit >= CREDIT_REFILL { + collector.consumed_since_credit = 0; + return Ok(Some(credit_frame(channel, CREDIT_REFILL))); + } + Ok(None) + } + + /// A `Close` frame arrived: the bytes are complete, the sender + /// reported an error, or the stream ended short of its declared + /// length. + pub fn on_close(&self, channel: u16, payload: &[u8]) -> Option<(K, StreamResult)> { + let collector = self + .open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&channel)?; + let result = match close_error(payload) { + Some(error) => Err(error), + None => match collector.declared_len { + Some(declared) if declared != collector.bytes.len() as u64 => Err(format!( + "ended after {} of the {declared} bytes declared", + collector.bytes.len() + )), + _ => Ok(collector.bytes), + }, + }; + Some((collector.key, result)) + } + + /// Drop whatever is collecting under `key`, so late frames on that + /// channel are dropped instead of kept. + pub fn abandon(&self, key: &K) { + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|_, collector| collector.key != *key); + } + + /// Streams still collecting. + pub fn open_count(&self) -> usize { + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } + + /// The connection ended: drop every open stream. + pub fn clear(&self) { + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outbound; + + fn open(len: Option) -> StreamOpen { + StreamOpen { + purpose: ATTACHMENT_PURPOSE.into(), + request_id: Some(9), + upload_id: None, + mime: None, + len, + } + } + + #[tokio::test] + async fn a_stream_crosses_with_credit_and_collects_whole() { + let (out, mut queue) = outbound::channel(usize::MAX); + let senders = StreamSenders::new(Opener::Host); + let receivers = StreamReceivers::::new(MAX_IMAGE_BYTES); + let payload = vec![7u8; MAX_STREAM_FRAME_BYTES * 20 + 5]; + let mut sender = senders + .open(&out, open(Some(payload.len() as u64))) + .unwrap(); + let channel = sender.channel(); + let sending = tokio::spawn(async move { + sender.send_all(&payload).await?; + sender.wait_for_ack().await + }); + + // Pump frames from the sender's queue into the receiver, returning + // credit and the final acknowledgement the way the peer would. + let delivered = loop { + let frame = queue.recv().await.expect("frame"); + match frame.kind { + FrameKind::Open => { + let open: StreamOpen = serde_json::from_slice(&frame.payload).unwrap(); + receivers + .accept(frame.channel, open.request_id.unwrap(), open.len) + .unwrap(); + } + FrameKind::Data => { + if let Some(credit) = receivers.on_data(frame.channel, &frame.payload).unwrap() + { + senders.credit(credit.channel, decode_credit(&credit.payload).unwrap()); + } + } + FrameKind::Close => { + let delivered = receivers.on_close(frame.channel, &frame.payload); + senders.on_close(frame.channel, &[]); + break delivered; + } + FrameKind::Credit => unreachable!(), + } + }; + sending.await.unwrap().unwrap(); + assert_eq!(channel, 2, "hosts open even channels"); + assert_eq!(Opener::of_channel(channel), Opener::Host); + let (key, bytes) = delivered.unwrap(); + assert_eq!(key, 9); + let bytes = bytes.unwrap(); + assert_eq!(bytes.len(), MAX_STREAM_FRAME_BYTES * 20 + 5); + assert!(bytes.iter().all(|byte| *byte == 7)); + assert_eq!(receivers.open_count(), 0); + } + + #[tokio::test] + async fn a_dropped_sender_reports_an_error_and_connection_loss_fails_the_rest() { + let (out, mut queue) = outbound::channel(usize::MAX); + let senders = StreamSenders::new(Opener::Client); + let receivers = StreamReceivers::::new(MAX_IMAGE_BYTES); + let sender = senders.open(&out, open(None)).unwrap(); + assert_eq!(sender.channel(), 1, "clients open odd channels"); + drop(sender); + let open_frame = queue.recv().await.unwrap(); + receivers.accept(open_frame.channel, 1, None).unwrap(); + let close = queue.recv().await.unwrap(); + assert_eq!(close.kind, FrameKind::Close); + let (key, result) = receivers.on_close(close.channel, &close.payload).unwrap(); + assert_eq!(key, 1); + assert_eq!(result, Err("the sender gave up".to_string())); + + let other = senders.open(&out, open(None)).unwrap(); + assert_eq!(other.channel(), 3); + senders.fail_all("connection lost"); + assert_eq!(other.wait_for_ack().await.unwrap_err(), "connection lost"); + receivers.accept(5, 2, None).unwrap(); + receivers.clear(); + assert_eq!(receivers.open_count(), 0); + } + + #[tokio::test] + async fn a_refusal_stops_the_sender() { + let (out, mut queue) = outbound::channel(usize::MAX); + let senders = StreamSenders::new(Opener::Client); + let mut sender = senders.open(&out, open(None)).unwrap(); + let channel = sender.channel(); + senders.on_close(channel, &close_frame(channel, Some("too big")).payload); + // Enough frames to run out of credit and hear the refusal. + let payload = vec![0u8; MAX_STREAM_FRAME_BYTES * (INITIAL_CREDIT as usize + 1)]; + assert_eq!(sender.send_all(&payload).await.unwrap_err(), "too big"); + drop(sender); + let mut kinds = Vec::new(); + while let Some(frame) = queue.try_recv() { + kinds.push(frame.kind); + } + assert_eq!(kinds[0], FrameKind::Open); + assert_eq!( + *kinds.last().unwrap(), + FrameKind::Close, + "the drop tells the peer" + ); + } + + #[test] + fn a_receiver_holds_the_sender_to_its_limits() { + let receivers = StreamReceivers::<&str>::new(10); + assert!(receivers.accept(1, "big", Some(11)).is_err()); + receivers.accept(1, "declared", Some(4)).unwrap(); + assert!( + receivers.accept(1, "again", None).is_err(), + "channel in use" + ); + assert_eq!(receivers.on_data(1, b"12345").unwrap_err().0, "declared"); + assert!(receivers.on_data(1, b"late").unwrap().is_none()); + + receivers.accept(3, "short", Some(4)).unwrap(); + assert!(receivers.on_data(3, b"12").unwrap().is_none()); + let (key, result) = receivers.on_close(3, &[]).unwrap(); + assert_eq!(key, "short"); + assert!(result.unwrap_err().contains("2 of the 4")); + + receivers.accept(5, "unbounded", None).unwrap(); + assert!(receivers.on_data(5, &[0; 10]).unwrap().is_none()); + assert_eq!(receivers.on_data(5, b"1").unwrap_err().0, "unbounded"); + + receivers.accept(7, "abandoned", None).unwrap(); + receivers.abandon(&"abandoned"); + assert_eq!(receivers.open_count(), 0); + assert!(receivers.on_close(7, &[]).is_none()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/uploads.rs b/apps/maple-agent/crates/maple-remote/src/uploads.rs new file mode 100644 index 000000000..e5277229c --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/uploads.rs @@ -0,0 +1,411 @@ +//! Uploads: image bytes a client sends ahead of `run.send`, on streams it +//! opens. +//! +//! An image inline in a `run.send` request would be capped by the control +//! frame limit, so the client streams it first under an id it minted and +//! the request names the id. One connection keeps its own uploads: at most +//! [`MAX_UPLOADS_IN_FLIGHT`] collecting and [`MAX_COMPLETED_UPLOADS`] +//! finished but not yet named by a request, oldest dropped first. A +//! `run.send` consumes the uploads it names; the connection's teardown +//! drops the rest. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +use base64::Engine as _; + +use crate::frame::{Frame, FrameKind}; +use crate::streams::{MAX_IMAGE_BYTES, StreamOpen, StreamReceivers, UPLOAD_PURPOSE, close_frame}; + +/// Bytes one upload may carry: the host's own limit per image. +pub const MAX_UPLOAD_BYTES: usize = MAX_IMAGE_BYTES; +/// Uploads one connection may have collecting at once. +pub const MAX_UPLOADS_IN_FLIGHT: usize = 4; +/// Finished uploads one connection keeps until a `run.send` names them. +pub const MAX_COMPLETED_UPLOADS: usize = 16; + +/// Longest upload id or media type accepted, so neither can pad a log or +/// an error. +const MAX_LABEL_CHARS: usize = 128; + +/// One finished upload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Upload { + pub mime: String, + pub bytes: Vec, +} + +impl Upload { + /// The `data:` URL the runtime takes, rebuilt the way the client + /// built it before it streamed the bytes. + pub fn data_url(&self) -> String { + let mut url = + String::with_capacity(self.mime.len() + 16 + self.bytes.len().div_ceil(3) * 4); + url.push_str("data:"); + url.push_str(&self.mime); + url.push_str(";base64,"); + base64::engine::general_purpose::STANDARD.encode_string(&self.bytes, &mut url); + url + } +} + +/// The uploads of one connection. +pub struct Uploads { + receivers: StreamReceivers, + /// Media types of the uploads still collecting, by id. + in_flight: Mutex>, + /// Finished uploads, oldest first. + completed: Mutex>, +} + +impl Default for Uploads { + fn default() -> Self { + Self { + receivers: StreamReceivers::new(MAX_UPLOAD_BYTES), + in_flight: Mutex::new(HashMap::new()), + completed: Mutex::new(VecDeque::new()), + } + } +} + +impl Uploads { + /// A frame on a client-opened channel. Returns the frame to send + /// back: credit while the bytes flow, a `Close` acknowledging a + /// finished upload, or a `Close` naming why one was refused. + pub fn on_frame(&self, frame: &Frame) -> Option { + match frame.kind { + FrameKind::Open => self + .on_open(frame.channel, &frame.payload) + .err() + .map(|reason| close_frame(frame.channel, Some(&reason))), + FrameKind::Data => match self.receivers.on_data(frame.channel, &frame.payload) { + Ok(credit) => credit, + Err((upload_id, reason)) => { + self.forget_in_flight(&upload_id); + log::debug!("upload {upload_id} refused: {reason}"); + Some(close_frame(frame.channel, Some(&reason))) + } + }, + FrameKind::Close => { + let (upload_id, result) = self.receivers.on_close(frame.channel, &frame.payload)?; + let mime = self.forget_in_flight(&upload_id)?; + match result { + Ok(bytes) => { + self.keep(upload_id, Upload { mime, bytes }); + Some(close_frame(frame.channel, None)) + } + Err(reason) => { + log::debug!("upload {upload_id} ended early: {reason}"); + Some(close_frame(frame.channel, Some(&reason))) + } + } + } + // A client does not grant credit on its own stream. + FrameKind::Credit => None, + } + } + + fn on_open(&self, channel: u16, payload: &[u8]) -> Result<(), String> { + let open: StreamOpen = + serde_json::from_slice(payload).map_err(|error| format!("bad stream open: {error}"))?; + if open.purpose != UPLOAD_PURPOSE { + return Err(format!("a client cannot open a {} stream", open.purpose)); + } + let upload_id = open + .upload_id + .filter(|id| is_clean_label(id)) + .ok_or_else(|| "an upload needs an id".to_string())?; + let mime = open + .mime + .filter(|mime| is_clean_label(mime) && !mime.contains([',', ';'])) + .ok_or_else(|| "an upload needs a media type".to_string())?; + let len = open + .len + .ok_or_else(|| "an upload needs its length".to_string())?; + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if in_flight.len() >= MAX_UPLOADS_IN_FLIGHT { + return Err(format!( + "at most {MAX_UPLOADS_IN_FLIGHT} uploads may be in flight" + )); + } + if in_flight.contains_key(&upload_id) || self.has_completed(&upload_id) { + return Err(format!("upload {upload_id} already exists")); + } + self.receivers + .accept(channel, upload_id.clone(), Some(len)) + .map_err(|reason| format!("upload {upload_id} refused: {reason}"))?; + in_flight.insert(upload_id, mime); + Ok(()) + } + + fn forget_in_flight(&self, upload_id: &str) -> Option { + self.in_flight + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(upload_id) + } + + fn has_completed(&self, upload_id: &str) -> bool { + self.completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|(id, _)| id == upload_id) + } + + /// Keep a finished upload, dropping the oldest past the limit. + fn keep(&self, upload_id: String, upload: Upload) { + let mut completed = self + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + completed.push_back((upload_id, upload)); + while completed.len() > MAX_COMPLETED_UPLOADS { + if let Some((dropped, _)) = completed.pop_front() { + log::debug!("upload {dropped} dropped: too many unreferenced uploads"); + } + } + } + + /// Take every upload named, in order, or none of them: a request that + /// names one unknown id must not consume the others. + pub fn take_all(&self, upload_ids: &[String]) -> Result, String> { + let mut completed = self + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut positions = Vec::with_capacity(upload_ids.len()); + for upload_id in upload_ids { + let position = completed + .iter() + .position(|(id, _)| id == upload_id) + .filter(|position| !positions.contains(position)) + .ok_or_else(|| format!("unknown or incomplete upload {upload_id}"))?; + positions.push(position); + } + // Remove from the back so earlier positions stay valid, then + // restore the order the request named them in. + let mut order: Vec<(usize, usize)> = positions.into_iter().enumerate().collect(); + order.sort_by_key(|(_, position)| std::cmp::Reverse(*position)); + let mut taken: Vec<(usize, Upload)> = order + .into_iter() + .filter_map(|(index, position)| { + completed + .remove(position) + .map(|(_, upload)| (index, upload)) + }) + .collect(); + taken.sort_by_key(|(index, _)| *index); + Ok(taken.into_iter().map(|(_, upload)| upload).collect()) + } + + /// Uploads still collecting. + pub fn in_flight_count(&self) -> usize { + self.receivers.open_count() + } + + /// Uploads finished and not yet named. + pub fn completed_count(&self) -> usize { + self.completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } +} + +/// Short, printable, and without whitespace: fit for a log line and for +/// the header of a `data:` URL. +fn is_clean_label(label: &str) -> bool { + !label.is_empty() + && label.len() <= MAX_LABEL_CHARS + && label.bytes().all(|byte| byte.is_ascii_graphic()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::MAX_STREAM_FRAME_BYTES; + use crate::streams::{CREDIT_REFILL, StreamClose}; + use bytes::Bytes; + + fn open_frame(channel: u16, upload_id: &str, len: u64) -> Frame { + let open = StreamOpen { + purpose: UPLOAD_PURPOSE.into(), + request_id: None, + upload_id: Some(upload_id.into()), + mime: Some("image/png".into()), + len: Some(len), + }; + Frame { + channel, + kind: FrameKind::Open, + payload: Bytes::from(serde_json::to_vec(&open).unwrap()), + } + } + + fn data_frame(channel: u16, bytes: &[u8]) -> Frame { + Frame { + channel, + kind: FrameKind::Data, + payload: Bytes::copy_from_slice(bytes), + } + } + + fn refusal(frame: Option) -> String { + let frame = frame.expect("a refusal"); + assert_eq!(frame.kind, FrameKind::Close); + serde_json::from_slice::(&frame.payload) + .unwrap() + .error + .expect("an error") + } + + fn upload(uploads: &Uploads, channel: u16, id: &str, bytes: &[u8]) { + assert!( + uploads + .on_frame(&open_frame(channel, id, bytes.len() as u64)) + .is_none() + ); + for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { + let _ = uploads.on_frame(&data_frame(channel, chunk)); + } + let ack = uploads + .on_frame(&close_frame(channel, None)) + .expect("an acknowledgement"); + assert_eq!(ack.kind, FrameKind::Close); + assert!(ack.payload.is_empty(), "{:?}", ack.payload); + } + + #[test] + fn an_upload_is_collected_acknowledged_and_consumed_once() { + let uploads = Uploads::default(); + let bytes: Vec = (0..(MAX_STREAM_FRAME_BYTES * CREDIT_REFILL as usize + 3)) + .map(|i| (i % 7) as u8) + .collect(); + assert!( + uploads + .on_frame(&open_frame(1, "u1", bytes.len() as u64)) + .is_none() + ); + let mut credits = 0; + for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { + if let Some(frame) = uploads.on_frame(&data_frame(1, chunk)) { + assert_eq!(frame.kind, FrameKind::Credit); + credits += 1; + } + } + assert_eq!(credits, 1); + assert_eq!(uploads.in_flight_count(), 1); + let ack = uploads.on_frame(&close_frame(1, None)).unwrap(); + assert!(ack.payload.is_empty()); + assert_eq!(uploads.in_flight_count(), 0); + assert_eq!(uploads.completed_count(), 1); + + let taken = uploads.take_all(&["u1".to_string()]).unwrap(); + assert_eq!(taken[0].bytes, bytes); + assert_eq!(taken[0].mime, "image/png"); + assert!(taken[0].data_url().starts_with("data:image/png;base64,")); + assert_eq!(uploads.completed_count(), 0); + assert!(uploads.take_all(&["u1".to_string()]).is_err(), "consumed"); + } + + #[test] + fn opens_are_validated_and_limited() { + let uploads = Uploads::default(); + assert!( + refusal(uploads.on_frame(&open_frame(1, "big", MAX_UPLOAD_BYTES as u64 + 1))) + .contains("byte limit") + ); + assert!(refusal(uploads.on_frame(&open_frame(1, "", 1))).contains("id")); + assert!(refusal(uploads.on_frame(&open_frame(1, "with space", 1))).contains("id")); + let mut bad_mime = StreamOpen { + purpose: UPLOAD_PURPOSE.into(), + request_id: None, + upload_id: Some("m".into()), + mime: Some("image/png;base64,AAAA".into()), + len: Some(1), + }; + let frame = |open: &StreamOpen| Frame { + channel: 1, + kind: FrameKind::Open, + payload: Bytes::from(serde_json::to_vec(open).unwrap()), + }; + assert!(refusal(uploads.on_frame(&frame(&bad_mime))).contains("media type")); + bad_mime.mime = Some("image/png".into()); + bad_mime.len = None; + assert!(refusal(uploads.on_frame(&frame(&bad_mime))).contains("length")); + bad_mime.len = Some(1); + bad_mime.purpose = "attachment".into(); + assert!(refusal(uploads.on_frame(&frame(&bad_mime))).contains("cannot open")); + assert!( + refusal(uploads.on_frame(&Frame { + channel: 1, + kind: FrameKind::Open, + payload: Bytes::from_static(b"nope"), + })) + .contains("bad stream open") + ); + + for index in 0..MAX_UPLOADS_IN_FLIGHT { + assert!( + uploads + .on_frame(&open_frame(1 + 2 * index as u16, &format!("f{index}"), 10)) + .is_none() + ); + } + assert!(refusal(uploads.on_frame(&open_frame(99, "one-more", 10))).contains("in flight")); + assert!(refusal(uploads.on_frame(&open_frame(101, "f0", 10))).contains("in flight")); + // A refused stream mid-way frees its slot. + assert!(refusal(uploads.on_frame(&data_frame(1, &[0; 11]))).contains("declared")); + assert_eq!(uploads.in_flight_count(), MAX_UPLOADS_IN_FLIGHT - 1); + assert!(refusal(uploads.on_frame(&open_frame(101, "f1", 10))).contains("already exists")); + assert!(uploads.on_frame(&open_frame(101, "f0", 10)).is_none()); + // A short stream is an error, not a completed upload. + assert!(refusal(uploads.on_frame(&close_frame(3, None))).contains("of the 10")); + assert_eq!(uploads.completed_count(), 0); + // Late frames on a dropped channel draw no answer. + assert!(uploads.on_frame(&data_frame(3, b"x")).is_none()); + assert!(uploads.on_frame(&close_frame(3, None)).is_none()); + } + + #[test] + fn completed_uploads_are_capped_and_taken_all_or_none() { + let uploads = Uploads::default(); + for index in 0..(MAX_COMPLETED_UPLOADS + 2) { + upload(&uploads, 1, &format!("c{index}"), b"abc"); + } + assert_eq!(uploads.completed_count(), MAX_COMPLETED_UPLOADS); + assert!( + uploads.take_all(&["c0".to_string()]).is_err(), + "the oldest were dropped" + ); + assert!(uploads.take_all(&["c1".to_string()]).is_err()); + let error = uploads + .take_all(&["c2".to_string(), "nope".to_string()]) + .unwrap_err(); + assert!(error.contains("nope"), "{error}"); + assert_eq!( + uploads.completed_count(), + MAX_COMPLETED_UPLOADS, + "a failed take consumes nothing" + ); + assert!( + uploads + .take_all(&["c2".to_string(), "c2".to_string()]) + .is_err(), + "one upload cannot be named twice" + ); + let taken = uploads + .take_all(&["c5".to_string(), "c2".to_string()]) + .unwrap(); + assert_eq!(taken.len(), 2); + assert_eq!(uploads.completed_count(), MAX_COMPLETED_UPLOADS - 2); + assert!(uploads.take_all(&[]).unwrap().is_empty()); + // A completed id is reserved until it is taken. + assert!(refusal(uploads.on_frame(&open_frame(1, "c3", 1))).contains("already exists")); + assert!(uploads.on_frame(&open_frame(1, "c2", 1)).is_none()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs new file mode 100644 index 000000000..61756977f --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -0,0 +1,790 @@ +//! The methods a client calls on a host, grouped by domain, and the +//! handshake both sides exchange first. +//! +//! Every request enum is tagged by the JSON-RPC method name with the +//! variant's fields as `params`, so `{ "method": "session.list", "params": +//! {...} }` decodes into `SessionRequest::List {...}`. A method without +//! params is a unit variant and carries no `params`. The server dispatches +//! by the prefix before the dot; each domain has its own controller. +//! +//! Compatibility: append-only. New params are `Option` with a default; +//! unknown fields are ignored on both sides. See the crate docs. + +use std::collections::BTreeMap; + +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentMcpServer, AgentSendMessageRequest, AgentSessionDetail, + AgentSessionIntegrationKind, AgentStartRequest, AgentTaskState, AgentTimelineItem, + SideQuestionTurn, +}; +use maple_agent::host::{HostBootstrap, HostEvent, HostSessionDefaults}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Bumped only for a change no feature flag can express. Mismatch refuses +/// the connection. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Feature flags a side advertises. Absent means off. +pub type Features = BTreeMap; + +/// The client streams images ahead of `run.send` and names them by upload +/// id; the host refuses images inline in the request. See +/// [`crate::uploads`]. +pub const UPLOAD_STREAMS_FEATURE: &str = "uploadStreams"; + +/// Features this build implements. Both sides send the same table; a +/// client gates a new call on the host's answer. +pub fn features() -> Features { + let mut features = Features::new(); + for name in [ + "timelinePaging", + "attachmentStreams", + "ping", + UPLOAD_STREAMS_FEATURE, + ] { + features.insert(name.to_string(), true); + } + features +} + +/// Whether `features` advertises `name`. +pub fn has_feature(features: &Features, name: &str) -> bool { + features.get(name).copied().unwrap_or(false) +} + +/// A peer's version for display: `0.1.0 (63bcff5c)`, or just `0.1.0` +/// when the peer sent no build. +pub fn version_label(version: &str, build: Option<&str>) -> String { + match build { + Some(build) => format!("{version} ({build})"), + None => version.to_string(), + } +} + +/// The notification method that carries host events. +pub const EVENT_METHOD: &str = "event"; + +/// First request on a connection. Everything else is refused before it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientHello { + pub protocol: u32, + pub app_version: String, + /// The git revision the client was built from, when its build knew + /// it. Absent from an older client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// The compile-time OpenSecret environment (`Production` or + /// `Development`). Two binaries built for different enclaves cannot + /// share a backend, so a mismatch is refused. + pub pcr_environment: String, + #[serde(default)] + pub features: Features, + pub device: DeviceInfo, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInfo { + /// The device's static public key, or a placeholder before pairing + /// exists. + pub public_key: String, + /// Display name, for the host's device list. + pub name: String, + /// The account the client is signed in as, for display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + +/// The host's answer to [`ClientHello`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostHello { + pub protocol: u32, + pub app_version: String, + /// The git revision the host was built from, when its build knew it. + /// Absent from an older host. Shown beside the version so two builds + /// of one version can be told apart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + pub pcr_environment: String, + /// Minted when the host process started; every sequence is scoped to + /// it. A different value after a reconnect means every cursor is + /// stale. + pub generation: String, + /// The connection's event sequence starts after this value. + pub seq: u64, + #[serde(default)] + pub features: Features, + pub host: HostInfo, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostInfo { + /// The host's identity: its static public key once pairing exists. + pub id: String, + pub name: String, + /// The account the host is signed in as, for display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + +/// One host event as delivered over a connection. `seq` increases by one +/// per event on that connection; a gap means events were lost and the +/// client must resync. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventEnvelope { + pub seq: u64, + pub event: HostEvent, +} + +/// A task snapshot as sent over the wire. The timeline is paged separately +/// (see [`SessionRequest::Timeline`]) so one message never carries a whole +/// long transcript; `detail.timeline` is empty here. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSnapshot { + pub detail: AgentSessionDetail, + /// Timeline items in the snapshot the pages read from. + pub timeline_len: usize, +} + +/// One page of a snapshot's timeline. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimelinePage { + pub items: Vec, + /// More items follow this page; fetch again from `offset + items.len()`. + pub has_more: bool, +} + +/// A bootstrap as sent over the wire: like [`HostBootstrap`] but with the +/// newest task's timeline paged separately. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapSnapshot { + pub bootstrap: HostBootstrap, + /// Timeline length of `bootstrap.latest`, whose timeline is empty here. + pub latest_timeline_len: usize, +} + +/// Where the bytes of an attachment arrive: on a binary stream the host +/// opened just before answering. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentHandle { + pub stream: u16, + pub len: u64, +} + +/// An image the client streamed to the host before `run.send`, named by +/// the id it minted for the upload stream. The media type travelled with +/// the stream; the name is what the message shows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadRef { + pub upload_id: String, + pub name: String, +} + +// ---- Domain requests -------------------------------------------------------- +// +// Each variant is one method. A variant with fields carries them as the +// `params` object, in camelCase; a unit variant has no `params`. + +/// `host.*`: the connection, the runtime, and host configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum HostRequest { + #[serde(rename = "host.hello")] + Hello(ClientHello), + #[serde(rename = "host.ping")] + Ping, + #[serde(rename = "host.bootstrap")] + Bootstrap, + #[serde(rename = "host.start_runtime")] + StartRuntime { request: Option }, + #[serde(rename = "host.stop_runtime")] + StopRuntime, + #[serde(rename = "host.session_defaults")] + SessionDefaults, + #[serde(rename = "host.set_session_defaults")] + SetSessionDefaults { defaults: HostSessionDefaults }, + #[serde(rename = "host.save_default_model")] + SaveDefaultModel { model: String }, + #[serde(rename = "host.usage_summary")] + UsageSummary, + #[serde(rename = "host.context_usage")] + ContextUsage { + session_id: String, + model: Option, + }, + #[serde(rename = "host.tool_summaries")] + ToolSummaries { session_id: String }, + #[serde(rename = "host.store_tool_summary")] + StoreToolSummary { + session_id: String, + item_id: String, + summary: String, + }, +} + +/// `project.*`: roots on the host's filesystem. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum ProjectRequest { + #[serde(rename = "project.recent_roots")] + RecentRoots, + #[serde(rename = "project.select_root")] + SelectRoot { path: String }, + #[serde(rename = "project.remove_root")] + RemoveRoot { + path: String, + fallback: Option, + }, + #[serde(rename = "project.suggest_directories")] + SuggestDirectories { query: String }, + #[serde(rename = "project.watch")] + Watch { path: String }, + #[serde(rename = "project.unwatch")] + Unwatch { path: String }, + #[serde(rename = "project.trust")] + Trust { path: String }, + #[serde(rename = "project.set_trust")] + SetTrust { path: String, trusted: bool }, +} + +/// `session.*`: tasks and their snapshots. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum SessionRequest { + #[serde(rename = "session.list")] + List { project_root: Option }, + #[serde(rename = "session.create")] + Create { + request: Option, + }, + /// Returns a [`SessionSnapshot`]; page its timeline with `Timeline`. + #[serde(rename = "session.load")] + Load { session_id: String }, + /// One page of the snapshot the last `Load` (or the bootstrap) built for + /// this task on this connection. Returns a [`TimelinePage`]. + #[serde(rename = "session.timeline")] + Timeline { + session_id: String, + offset: usize, + limit: usize, + }, + #[serde(rename = "session.rename")] + Rename { session_id: String, title: String }, + #[serde(rename = "session.set_state")] + SetState { + session_id: String, + state: AgentTaskState, + }, + #[serde(rename = "session.delete")] + Delete { session_id: String }, + #[serde(rename = "session.compact")] + Compact { session_id: String }, + #[serde(rename = "session.subagents")] + Subagents { session_id: String }, + #[serde(rename = "session.cancel_external_agent")] + CancelExternalAgent { + session_id: String, + agent_id: String, + }, + #[serde(rename = "session.set_permission_mode")] + SetPermissionMode { session_id: String, mode: String }, + #[serde(rename = "session.set_web_enabled")] + SetWebEnabled { session_id: String, enabled: bool }, + /// Returns an [`AttachmentHandle`]; the bytes arrive on its stream. + #[serde(rename = "session.read_attachment")] + ReadAttachment { + session_id: String, + attachment_id: String, + }, +} + +/// `run.*`: messages, runs, and the prompts they raise. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum RunRequest { + /// `request.attachments` must be empty on the wire: images are + /// streamed first and named in `uploads`. The host rebuilds the + /// request's attachments from them before it sends the message. + #[serde(rename = "run.send")] + Send { + request: AgentSendMessageRequest, + #[serde(default)] + uploads: Vec, + }, + #[serde(rename = "run.cancel")] + Cancel { run_id: String }, + #[serde(rename = "run.cancel_queued")] + CancelQueued { + session_id: String, + queue_id: String, + }, + #[serde(rename = "run.begin_queued_edit")] + BeginQueuedEdit { + session_id: String, + queue_id: String, + }, + #[serde(rename = "run.end_queued_edit")] + EndQueuedEdit { + session_id: String, + queue_id: String, + }, + #[serde(rename = "run.answer_question")] + AnswerQuestion { request_id: String, answer: String }, + #[serde(rename = "run.permission_respond")] + PermissionRespond { + session_id: String, + request_id: String, + allow: bool, + }, + #[serde(rename = "run.ask_side_question")] + AskSideQuestion { + session_id: String, + request_id: String, + prior: Vec, + question: String, + }, + #[serde(rename = "run.summarize_tool_call")] + SummarizeToolCall { + session_id: String, + tool_name: String, + input: Option, + output_text: String, + }, + #[serde(rename = "run.summarize_thinking")] + SummarizeThinking { + session_id: String, + thinking_text: String, + }, +} + +/// `model.*`: the catalog and skills. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum ModelRequest { + #[serde(rename = "model.list")] + List, + #[serde(rename = "model.supports_vision")] + SupportsVision { model: String }, + #[serde(rename = "model.slash_commands")] + SlashCommands { working_dir: Option }, + #[serde(rename = "model.resolve_slash_command")] + ResolveSlashCommand { + working_dir: Option, + command: String, + args: String, + }, +} + +/// `integration.*`: MCP servers and curated integrations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum IntegrationRequest { + #[serde(rename = "integration.list_session_mcp")] + ListSessionMcp { session_id: String }, + #[serde(rename = "integration.set_session_mcp")] + SetSessionMcp { + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + }, + #[serde(rename = "integration.list_mcp")] + ListMcp, + #[serde(rename = "integration.save_mcp")] + SaveMcp { servers: Vec }, + #[serde(rename = "integration.list")] + List, + #[serde(rename = "integration.set_enabled")] + SetEnabled { id: String, enabled: bool }, +} + +/// A request decoded into its domain. +#[derive(Debug, Clone)] +pub enum Request { + Host(HostRequest), + Project(ProjectRequest), + Session(SessionRequest), + Run(RunRequest), + Model(ModelRequest), + Integration(IntegrationRequest), +} + +/// Outcome of decoding a method name and its params. +#[derive(Debug)] +pub enum DecodeError { + /// No domain owns this method. + UnknownMethod(String), + /// The domain knows the method but the params do not fit. + InvalidParams(String), +} + +/// Every method name, by domain, in the order of the enum variants. The +/// test below checks each list against the variants serde knows, so a new +/// variant fails a test until it is listed here. +pub const HOST_METHODS: &[&str] = &[ + "host.hello", + "host.ping", + "host.bootstrap", + "host.start_runtime", + "host.stop_runtime", + "host.session_defaults", + "host.set_session_defaults", + "host.save_default_model", + "host.usage_summary", + "host.context_usage", + "host.tool_summaries", + "host.store_tool_summary", +]; +pub const PROJECT_METHODS: &[&str] = &[ + "project.recent_roots", + "project.select_root", + "project.remove_root", + "project.suggest_directories", + "project.watch", + "project.unwatch", + "project.trust", + "project.set_trust", +]; +pub const SESSION_METHODS: &[&str] = &[ + "session.list", + "session.create", + "session.load", + "session.timeline", + "session.rename", + "session.set_state", + "session.delete", + "session.compact", + "session.subagents", + "session.cancel_external_agent", + "session.set_permission_mode", + "session.set_web_enabled", + "session.read_attachment", +]; +pub const RUN_METHODS: &[&str] = &[ + "run.send", + "run.cancel", + "run.cancel_queued", + "run.begin_queued_edit", + "run.end_queued_edit", + "run.answer_question", + "run.permission_respond", + "run.ask_side_question", + "run.summarize_tool_call", + "run.summarize_thinking", +]; +pub const MODEL_METHODS: &[&str] = &[ + "model.list", + "model.supports_vision", + "model.slash_commands", + "model.resolve_slash_command", +]; +pub const INTEGRATION_METHODS: &[&str] = &[ + "integration.list_session_mcp", + "integration.set_session_mcp", + "integration.list_mcp", + "integration.save_mcp", + "integration.list", + "integration.set_enabled", +]; + +/// Whether a host answers `method` at all. Deciding this by name, rather +/// than by reading serde's error text, keeps a bad enum value inside the +/// params from reading as an unknown method. +pub fn is_known_method(method: &str) -> bool { + [ + HOST_METHODS, + PROJECT_METHODS, + SESSION_METHODS, + RUN_METHODS, + MODEL_METHODS, + INTEGRATION_METHODS, + ] + .iter() + .any(|methods| methods.contains(&method)) +} + +/// Decode a JSON-RPC request by its method's domain prefix. +/// +/// A method without params is a unit variant, which serde accepts with +/// `params` missing or `null` but not `{}`; a method whose params are all +/// optional is a struct variant, which needs `{}`. A missing, `null`, or +/// empty `params` is tried both ways so either client shape decodes. +pub fn decode_request(method: &str, params: Value) -> Result { + if !is_known_method(method) { + return Err(DecodeError::UnknownMethod(method.to_string())); + } + let empty = params.is_null() || params.as_object().is_some_and(|object| object.is_empty()); + if !empty { + return decode_tagged( + method, + serde_json::json!({ "method": method, "params": params }), + ); + } + decode_tagged( + method, + serde_json::json!({ "method": method, "params": {} }), + ) + .or_else(|first| { + decode_tagged(method, serde_json::json!({ "method": method })).map_err(|_| first) + }) +} + +fn decode_tagged(method: &str, tagged: Value) -> Result { + fn decode(tagged: Value) -> Result { + serde_json::from_value(tagged) + .map_err(|error| DecodeError::InvalidParams(error.to_string())) + } + let (domain, _) = method + .split_once('.') + .ok_or_else(|| DecodeError::UnknownMethod(method.to_string()))?; + Ok(match domain { + "host" => Request::Host(decode(tagged)?), + "project" => Request::Project(decode(tagged)?), + "session" => Request::Session(decode(tagged)?), + "run" => Request::Run(decode(tagged)?), + "model" => Request::Model(decode(tagged)?), + "integration" => Request::Integration(decode(tagged)?), + _ => return Err(DecodeError::UnknownMethod(method.to_string())), + }) +} + +/// The method name and params of a typed request, for the client side. A +/// unit variant has no params and yields `Value::Null`, which the request +/// omits. +pub fn encode_request(request: &T) -> Result<(String, Value), String> { + let value = serde_json::to_value(request).map_err(|error| error.to_string())?; + let method = value + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| "request has no method".to_string())? + .to_string(); + let params = value.get("params").cloned().unwrap_or(Value::Null); + Ok((method, params)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_decode_by_domain_and_refuse_unknown_methods() { + let (method, params) = encode_request(&SessionRequest::List { + project_root: Some("/p".to_string()), + }) + .unwrap(); + assert_eq!(method, "session.list"); + match decode_request(&method, params).unwrap() { + Request::Session(SessionRequest::List { project_root }) => { + assert_eq!(project_root.as_deref(), Some("/p")); + } + other => panic!("wrong decode: {other:?}"), + } + assert!(matches!( + decode_request("session.nope", Value::Null), + Err(DecodeError::UnknownMethod(_)) + )); + assert!(matches!( + decode_request("nodot", Value::Null), + Err(DecodeError::UnknownMethod(_)) + )); + assert!(matches!( + decode_request("run.cancel", serde_json::json!({"wrong": 1})), + Err(DecodeError::InvalidParams(_)) + )); + // A bad enum value inside the params is a params error, not an + // unknown method, even though serde reports it as a variant. + let bad_kind = serde_json::json!({ + "sessionId": "s1", "name": "n", "kind": "teleporter", "enabled": true + }); + match decode_request("integration.set_session_mcp", bad_kind) { + Err(DecodeError::InvalidParams(text)) => { + assert!(text.contains("teleporter"), "{text}"); + } + other => panic!("wrong decode: {other:?}"), + } + } + + #[test] + fn params_keep_their_wire_shape_and_empty_params_decode_both_ways() { + // A method with params: the same JSON as before the variants held + // their own structs. + let (method, params) = encode_request(&SessionRequest::Rename { + session_id: "s1".to_string(), + title: "T".to_string(), + }) + .unwrap(); + assert_eq!(method, "session.rename"); + assert_eq!(params, serde_json::json!({"sessionId": "s1", "title": "T"})); + // A method without params has none. + let (method, params) = encode_request(&HostRequest::Ping).unwrap(); + assert_eq!(method, "host.ping"); + assert_eq!(params, Value::Null); + for params in [Value::Null, serde_json::json!({})] { + assert!(matches!( + decode_request("host.ping", params.clone()), + Ok(Request::Host(HostRequest::Ping)) + )); + // All-optional params decode from nothing as well. + assert!(matches!( + decode_request("session.list", params), + Ok(Request::Session(SessionRequest::List { + project_root: None + })) + )); + } + assert!(matches!( + decode_request("host.ping", serde_json::json!({"extra": 1})), + Err(DecodeError::InvalidParams(_)) + )); + assert!(matches!( + decode_request("session.rename", Value::Null), + Err(DecodeError::InvalidParams(_)) + )); + } + + /// The names serde accepts as tags for `T`, read from its error for a + /// tag it does not know. + fn serde_variants( + domain: &str, + ) -> Vec { + let probe = serde_json::json!({ "method": format!("{domain}.__probe__"), "params": {} }); + let text = serde_json::from_value::(probe).unwrap_err().to_string(); + let (_, expected) = text + .split_once("expected one of ") + .unwrap_or_else(|| panic!("no variant list in {text:?}")); + expected + .split(", ") + .map(|name| name.trim().trim_matches('`').to_string()) + .filter(|name| !name.is_empty()) + .collect() + } + + #[test] + fn the_method_lists_match_the_enums() { + assert_eq!(serde_variants::("host"), HOST_METHODS); + assert_eq!(serde_variants::("project"), PROJECT_METHODS); + assert_eq!(serde_variants::("session"), SESSION_METHODS); + assert_eq!(serde_variants::("run"), RUN_METHODS); + assert_eq!(serde_variants::("model"), MODEL_METHODS); + assert_eq!( + serde_variants::("integration"), + INTEGRATION_METHODS + ); + for method in HOST_METHODS.iter().chain(SESSION_METHODS) { + assert!(is_known_method(method)); + assert!(method.starts_with("host.") || method.starts_with("session.")); + } + assert!(!is_known_method("host.__probe__")); + } + + #[test] + fn unknown_fields_are_ignored_and_optional_fields_default() { + let params = serde_json::json!({ + "protocol": 1, + "appVersion": "0.1.0", + "pcrEnvironment": "Production", + "device": {"publicKey": "k", "name": "laptop", "futureField": true}, + "somethingNew": {"nested": 1} + }); + let hello: ClientHello = serde_json::from_value(params).unwrap(); + assert!(hello.features.is_empty()); + assert_eq!(hello.device.user_id, None); + assert_eq!(hello.build, None); + let json = serde_json::to_value(&hello).unwrap(); + assert!(json.get("somethingNew").is_none()); + assert!(json.get("build").is_none(), "an absent build is not sent"); + } + + #[test] + fn hellos_round_trip_with_and_without_a_build() { + let mut client = ClientHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: Some("63bcff5c".to_string()), + pcr_environment: "Production".to_string(), + features: features(), + device: DeviceInfo { + public_key: "k".to_string(), + name: "laptop".to_string(), + user_id: None, + }, + }; + let json = serde_json::to_value(&client).unwrap(); + assert_eq!(json["build"], "63bcff5c"); + assert_eq!(serde_json::from_value::(json).unwrap(), client); + client.build = None; + let json = serde_json::to_value(&client).unwrap(); + assert!(json.get("build").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), client); + + let mut host = HostHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: Some("63bcff5c".to_string()), + pcr_environment: "Production".to_string(), + generation: "gen".to_string(), + seq: 0, + features: features(), + host: HostInfo { + id: "h".to_string(), + name: "workstation".to_string(), + user_id: None, + }, + }; + let json = serde_json::to_value(&host).unwrap(); + assert_eq!(json["build"], "63bcff5c"); + assert_eq!(serde_json::from_value::(json).unwrap(), host); + host.build = None; + let json = serde_json::to_value(&host).unwrap(); + assert!(json.get("build").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), host); + + // What an older host sends: no `build` at all. + let older: HostHello = serde_json::from_value(serde_json::json!({ + "protocol": 1, + "appVersion": "0.1.0", + "pcrEnvironment": "Production", + "generation": "gen", + "seq": 0, + "host": {"id": "h", "name": "workstation"} + })) + .unwrap(); + assert_eq!(older.build, None); + assert_eq!( + version_label(&older.app_version, older.build.as_deref()), + "0.1.0" + ); + assert_eq!(version_label("0.1.0", Some("63bcff5c")), "0.1.0 (63bcff5c)"); + } + + #[test] + fn features_table_has_this_builds_flags() { + assert_eq!(features().get("timelinePaging"), Some(&true)); + assert!(has_feature(&features(), UPLOAD_STREAMS_FEATURE)); + assert!(!has_feature(&Features::new(), UPLOAD_STREAMS_FEATURE)); + } + + #[test] + fn a_send_without_uploads_decodes_from_an_older_client() { + let params = serde_json::json!({ + "request": {"sessionId": "s1", "text": "hi", "model": null, "mode": null} + }); + match decode_request("run.send", params).unwrap() { + Request::Run(RunRequest::Send { request, uploads }) => { + assert_eq!(request.session_id, "s1"); + assert!(uploads.is_empty()); + } + other => panic!("wrong decode: {other:?}"), + } + } +} diff --git a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs new file mode 100644 index 000000000..e7db34529 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs @@ -0,0 +1,486 @@ +//! Shared fixtures: a scripted host and handshake values. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, AgentMcpServer, + AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, AgentTaskState, AgentTimelineItem, RecentProjectRoot, SideQuestionTurn, +}; +use maple_agent::host::{ + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, UsageSummary, +}; +use maple_remote::client::ClientConfig; +use maple_remote::devices::DeviceStore; +use maple_remote::keys::StaticKey; +use maple_remote::listen::{HostStores, serve_listener}; +use maple_remote::pairing::{PairingLimiter, PendingPairingStore}; +use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; +use maple_remote::wire::{ClientHello, DeviceInfo, HostInfo, PROTOCOL_VERSION}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +/// A host listening on a real loopback port, with its stores in a +/// temporary directory that goes when the host is dropped. +pub struct Host { + pub address: String, + pub key: StaticKey, + pub devices: Arc, + pub pending: Arc, + pub shutdown: CancellationToken, + _dir: TempDir, +} + +pub struct TempDir(pub std::path::PathBuf); + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Serve `fake` on a loopback port with `identity()` and a device hook +/// that records every accepted hello, like the app's. +pub async fn start_host(fake: Arc) -> Host { + let dir = std::env::temp_dir().join(format!("maple-transport-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let key = StaticKey::load_or_create(&dir.join("host_key.json")).unwrap(); + let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); + let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); + let hook_devices = Arc::clone(&devices); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |hello: &ClientHello| { + hook_devices + .touch( + &hello.device.public_key, + &hello.device.name, + hello.device.user_id.as_deref(), + ) + .unwrap(); + })), + ..Default::default() + }; + let server = HostServer::new( + fake, + HostInfo { + id: key.public_id(), + ..info() + }, + identity(), + config, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let shutdown = CancellationToken::new(); + let stores = Arc::new(HostStores { + key: key.clone(), + devices: Arc::clone(&devices), + pending_pairing: Arc::clone(&pending), + limiter: PairingLimiter::new(3, Duration::from_secs(60)), + }); + tokio::spawn(serve_listener(listener, server, stores, shutdown.clone())); + Host { + address, + key, + devices, + pending, + shutdown, + _dir: TempDir(dir), + } +} + +/// A host whose answers are fixed and whose calls are counted. +pub struct FakeHost { + pub id: HostId, + pub events: Arc, + pub timeline_len: usize, + pub attachment: Vec, + pub loads: AtomicUsize, + /// Every root passed to `unwatch_project_root`, in order. + pub unwatched: std::sync::Mutex>, + /// Every request passed to `send_message`, in order. + pub sent: std::sync::Mutex>, +} + +pub fn summary(id: &str) -> AgentSessionSummary { + AgentSessionSummary { + id: id.to_string(), + title: format!("Task {id}"), + project_root: "/p".to_string(), + created_ms: 1, + updated_ms: 2, + message_count: 0, + model: None, + mode: "smart_approve".to_string(), + web_enabled: true, + state: AgentTaskState::Active, + acp: false, + } +} + +pub fn item(index: usize) -> AgentTimelineItem { + AgentTimelineItem { + id: format!("item-{index}"), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some("x".repeat(3000)), + status: None, + input: None, + output: None, + created_ms: index as u128, + merge: "none".to_string(), + } +} + +impl FakeHost { + pub fn new(timeline_len: usize) -> Arc { + Arc::new(Self { + id: HostId::new("fake"), + events: Arc::new(HostEventHub::default()), + timeline_len, + attachment: (0..(600 * 1024)).map(|i| (i % 251) as u8).collect(), + loads: AtomicUsize::new(0), + unwatched: std::sync::Mutex::new(Vec::new()), + sent: std::sync::Mutex::new(Vec::new()), + }) + } + + pub fn detail(&self, id: &str) -> AgentSessionDetail { + AgentSessionDetail { + session: summary(id), + timeline: (0..self.timeline_len).map(item).collect(), + mcp_errors: Vec::new(), + queue: AgentDesktopQueueSnapshot { + revision: 0, + items: Vec::new(), + }, + } + } +} + +fn unsupported() -> Result { + Err("unsupported in the fake host".to_string()) +} + +#[async_trait] +impl HostBackend for FakeHost { + fn id(&self) -> &HostId { + &self.id + } + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + async fn bootstrap(&self) -> Result { + Ok(HostBootstrap { + project_root: Some("/p".to_string()), + sessions: vec![summary("s1"), summary("s2")], + recent_roots: vec!["/p".to_string()], + latest: Some(self.detail("s1")), + session_defaults: HostSessionDefaults { + permission_mode: "auto".to_string(), + ..Default::default() + }, + }) + } + async fn start_runtime( + &self, + _request: Option, + ) -> Result { + Ok(AgentRuntimeStatus { + running: true, + project_root: Some("/p".to_string()), + model: None, + mode: None, + active_runs: HashMap::new(), + }) + } + async fn stop_runtime(&self) -> Result { + unsupported() + } + async fn recent_project_roots(&self) -> Result, String> { + unsupported() + } + async fn select_project_root( + &self, + path: String, + ) -> Result { + Err(format!("cannot select {path}")) + } + async fn remove_project_root(&self, _: String, _: Option) -> Result<(), String> { + unsupported() + } + async fn suggest_directories(&self, query: String) -> Result, String> { + Ok(vec![DirectorySuggestion { + path: format!("{query}dir"), + name: "dir".to_string(), + }]) + } + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.events.publish(HostEvent::ProjectBranch { + project_root: path, + branch: Some("main".to_string()), + }); + Ok(()) + } + async fn unwatch_project_root(&self, path: String) -> Result<(), String> { + self.unwatched.lock().unwrap().push(path); + Ok(()) + } + async fn project_trust(&self, _: String) -> Result { + unsupported() + } + async fn set_project_trust( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn list_sessions(&self, _: Option) -> Result, String> { + Ok(vec![summary("s1"), summary("s2")]) + } + async fn create_session( + &self, + _: Option, + ) -> Result { + unsupported() + } + async fn load_session(&self, session_id: String) -> Result { + self.loads.fetch_add(1, Ordering::Relaxed); + Ok(self.detail(&session_id)) + } + async fn rename_session( + &self, + id: String, + title: String, + ) -> Result { + let mut renamed = summary(&id); + renamed.title = title; + Ok(renamed) + } + async fn set_session_state( + &self, + _: String, + _: AgentTaskState, + ) -> Result { + unsupported() + } + async fn delete_session(&self, _: String) -> Result<(), String> { + unsupported() + } + async fn compact_session(&self, _: String) -> Result<(), String> { + unsupported() + } + async fn session_subagents(&self, _: String) -> Result, String> { + Ok(Vec::new()) + } + async fn cancel_external_agent(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn set_permission_mode(&self, _: String, _: String) -> Result<(), String> { + Ok(()) + } + async fn set_session_web_enabled( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn context_usage( + &self, + _: String, + _: Option, + ) -> Result, String> { + Ok(Some(ContextUsage { + tokens: 10, + limit: 100, + })) + } + async fn read_image_attachment(&self, _: String, id: String) -> Result, String> { + if id == "missing" { + return Err("no such attachment".to_string()); + } + Ok(self.attachment.clone()) + } + async fn send_message(&self, request: AgentSendMessageRequest) -> Result { + let run = format!("run-for-{}", request.session_id); + self.sent.lock().unwrap().push(request); + Ok(run) + } + async fn cancel_run(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn cancel_queued_message( + &self, + _: String, + _: String, + ) -> Result { + unsupported() + } + async fn begin_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn end_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn answer_question(&self, _: String, _: String) -> Result { + Ok(true) + } + async fn permission_respond(&self, _: String, _: String, _: bool) -> Result<(), String> { + Ok(()) + } + async fn ask_side_question( + &self, + _: String, + _: String, + _: Vec, + _: String, + ) -> Result<(), String> { + unsupported() + } + async fn summarize_tool_call( + &self, + _: String, + _: String, + _: Option, + _: String, + ) -> Result, String> { + Ok(None) + } + async fn summarize_thinking(&self, _: String, _: String) -> Result, String> { + Ok(None) + } + async fn tool_summaries(&self, _: String) -> Result, String> { + Ok(HashMap::from([( + "item-1".to_string(), + "did a thing".to_string(), + )])) + } + async fn store_tool_summary(&self, _: String, _: String, _: String) -> Result<(), String> { + Ok(()) + } + async fn available_model_ids(&self) -> Result, String> { + Ok(vec!["m1".to_string()]) + } + async fn model_supports_vision(&self, _: String) -> Result, String> { + Ok(Some(true)) + } + async fn list_slash_commands( + &self, + _: Option, + ) -> Result, String> { + Ok(Vec::new()) + } + async fn resolve_slash_command( + &self, + _: Option, + _: String, + _: String, + ) -> Result, String> { + Ok(None) + } + async fn list_session_mcp_servers( + &self, + _: String, + ) -> Result, String> { + Ok(Vec::new()) + } + async fn set_session_mcp_server_enabled( + &self, + _: String, + _: String, + _: AgentSessionIntegrationKind, + _: bool, + ) -> Result, String> { + unsupported() + } + async fn list_mcp_servers(&self) -> Result, String> { + Ok(Vec::new()) + } + async fn save_mcp_servers( + &self, + _: Vec, + ) -> Result, String> { + unsupported() + } + async fn list_integrations(&self) -> Result, String> { + Ok(Vec::new()) + } + async fn set_integration_enabled( + &self, + _: String, + _: bool, + ) -> Result, String> { + unsupported() + } + async fn setup_integration(&self, _: String) -> Result, String> { + unsupported() + } + async fn session_defaults(&self) -> Result { + Ok(HostSessionDefaults::default()) + } + async fn set_session_defaults(&self, _: HostSessionDefaults) -> Result<(), String> { + Ok(()) + } + async fn save_default_model(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn usage_summary(&self) -> Result { + Ok(UsageSummary::default()) + } +} + +pub fn identity() -> HostIdentity { + HostIdentity { + app_version: "0.1.0".to_string(), + build: Some("abc1234".to_string()), + pcr_environment: "Development".to_string(), + } +} + +pub fn info() -> HostInfo { + HostInfo { + id: "host-key".to_string(), + name: "workstation".to_string(), + user_id: None, + } +} + +pub fn hello() -> ClientHello { + ClientHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: None, + pcr_environment: "Development".to_string(), + features: maple_remote::wire::features(), + device: DeviceInfo { + public_key: "device-key".to_string(), + name: "laptop".to_string(), + user_id: None, + }, + } +} + +pub fn client_config() -> ClientConfig { + ClientConfig { + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(5), + long_request_timeout: Duration::from_secs(5), + ping_interval: Duration::from_millis(50), + ping_timeout: Duration::from_millis(200), + ping_misses: 2, + timeline_page_items: 50, + ..Default::default() + } +} diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs new file mode 100644 index 000000000..ea4002d13 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -0,0 +1,863 @@ +//! A host server and a remote client over an in-process carrier, with a +//! scripted host behind the server. + +mod common; + +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use bytes::Bytes; +use common::{FakeHost, client_config, hello, identity, info, summary}; +use maple_agent::agent::{AgentImageUpload, AgentSendMessageRequest, AgentServiceEvent}; +use maple_agent::host::{ContextUsage, HostBackend, HostEvent}; +use maple_remote::carrier::{Carrier, FrameSink, FrameStream, in_process_pair}; +use maple_remote::client::RemoteHostBackend; +use maple_remote::frame::{CONTROL_CHANNEL, Frame, FrameKind}; +use maple_remote::rpc::{self, Message, Response}; +use maple_remote::server::{HostServer, HostServerConfig, MAX_WATCHED_ROOTS}; +use maple_remote::streams::{StreamClose, StreamOpen, UPLOAD_PURPOSE}; +use maple_remote::uploads::MAX_UPLOAD_BYTES; +use maple_remote::wire::{EventEnvelope, HostHello, PROTOCOL_VERSION}; + +/// Start a server on a fresh pair and connect a client through it. +async fn connect( + host: Arc, + config: HostServerConfig, +) -> ( + Arc, + tokio::task::JoinHandle>, +) { + let (client_side, host_side) = in_process_pair(64); + let server = HostServer::new(host, info(), identity(), config); + let serving = tokio::spawn(server.serve(host_side)); + let client = RemoteHostBackend::connect(client_side, hello(), client_config()) + .await + .expect("connect"); + (client, serving) +} + +#[tokio::test] +async fn handshake_refuses_a_different_environment_and_protocol() { + for (protocol, environment) in [(PROTOCOL_VERSION, "Production"), (99, "Development")] { + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new( + FakeHost::new(0), + info(), + identity(), + HostServerConfig::default(), + ); + let serving = tokio::spawn(server.serve(host_side)); + let mut hello = hello(); + hello.protocol = protocol; + hello.pcr_environment = environment.to_string(); + let error = RemoteHostBackend::connect(client_side, hello, client_config()) + .await + .err() + .expect("refused"); + assert!( + error.contains("environment") || error.contains("protocol"), + "{error}" + ); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .expect("server ends after a refusal") + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn snapshots_page_completely_and_calls_round_trip() { + let host = FakeHost::new(230); + let (client, serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + assert_eq!(client.id().as_str(), "host-key"); + assert_eq!(client.host_hello().host.name, "workstation"); + + let boot = client.bootstrap().await.unwrap(); + assert_eq!(boot.sessions.len(), 2); + assert_eq!(boot.session_defaults.permission_mode, "auto"); + let latest = boot.latest.unwrap(); + assert_eq!( + latest.timeline.len(), + 230, + "every page of the bootstrap snapshot arrives" + ); + assert_eq!(latest.timeline[229].id, "item-229"); + + let detail = client.load_session("s2".to_string()).await.unwrap(); + assert_eq!(detail.timeline.len(), 230); + assert_eq!(detail.session.id, "s2"); + // The bootstrap loaded s1 once and the load loaded s2 once; paging read + // the kept snapshots instead of loading again. + assert_eq!(host.loads.load(Ordering::Relaxed), 1); + + assert_eq!( + client + .send_message(AgentSendMessageRequest { + session_id: "s1".to_string(), + text: "hi".to_string(), + model: None, + context_limit: None, + mode: None, + vision_capable: false, + steer: false, + queue_id: None, + attachments: Vec::new(), + }) + .await + .unwrap(), + "run-for-s1" + ); + assert_eq!( + client + .rename_session("s1".to_string(), "Renamed".to_string()) + .await + .unwrap() + .title, + "Renamed" + ); + assert_eq!( + client.context_usage("s1".to_string(), None).await.unwrap(), + Some(ContextUsage { + tokens: 10, + limit: 100 + }) + ); + assert_eq!( + client + .tool_summaries("s1".to_string()) + .await + .unwrap() + .get("item-1") + .map(String::as_str), + Some("did a thing") + ); + assert_eq!( + client + .select_project_root("/x".to_string()) + .await + .unwrap_err(), + "cannot select /x", + "host errors keep their message" + ); + assert_eq!( + client.suggest_directories("/p/".to_string()).await.unwrap()[0].path, + "/p/dir" + ); + + client.close().await; + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn events_arrive_in_order_through_the_hub() { + let host = FakeHost::new(0); + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let mut events = client.subscribe(); + host.events.publish(HostEvent::Service(Box::new( + AgentServiceEvent::SessionCreated(summary("s9")), + ))); + client.watch_project_root("/p".to_string()).await.unwrap(); + let first = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + first, + HostEvent::Service(ref event) if matches!(**event, AgentServiceEvent::SessionCreated(ref s) if s.id == "s9") + )); + let second = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + second, + HostEvent::ProjectBranch { ref branch, .. } if branch.as_deref() == Some("main") + )); +} + +#[tokio::test] +async fn attachments_stream_whole_and_a_missing_one_is_an_error() { + let host = FakeHost::new(0); + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let bytes = client + .read_image_attachment("s1".to_string(), "a1".to_string()) + .await + .unwrap(); + assert_eq!(bytes, host.attachment); + let error = client + .read_image_attachment("s1".to_string(), "missing".to_string()) + .await + .unwrap_err(); + assert_eq!(error, "no such attachment"); +} + +/// A raw peer that answers the handshake by hand and then sends whatever +/// frames the test wants, so sequences can be forged. +async fn raw_host(mut carrier: Carrier, frames: Vec) { + let request = carrier.stream.recv().await.expect("hello"); + let Message::Request(request) = rpc::decode(&request.payload).unwrap() else { + panic!("first message must be the hello request"); + }; + assert_eq!(request.method, "host.hello"); + let answer = HostHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: None, + pcr_environment: "Development".to_string(), + generation: "gen".to_string(), + seq: 0, + features: Default::default(), + host: info(), + }; + let response = Response::ok(request.id, serde_json::to_value(answer).unwrap()); + carrier + .sink + .send(Frame::control( + rpc::encode(&Message::Response(response)).unwrap(), + )) + .await + .unwrap(); + for frame in frames { + carrier.sink.send(frame).await.unwrap(); + } + // Keep the connection open until the test drops the client. + while carrier.stream.recv().await.is_some() {} +} + +fn event_frame(seq: u64) -> Frame { + let envelope = EventEnvelope { + seq, + event: HostEvent::ProjectBranch { + project_root: "/p".to_string(), + branch: Some(format!("b{seq}")), + }, + }; + Frame::control( + rpc::encode(&Message::Notification(rpc::Notification::new( + "event", + serde_json::to_value(envelope).unwrap(), + ))) + .unwrap(), + ) +} + +#[tokio::test] +async fn a_sequence_gap_publishes_a_resync_before_the_event() { + let (client_side, host_side) = in_process_pair(16); + let peer = tokio::spawn(raw_host( + host_side, + vec![event_frame(1), event_frame(2), event_frame(4)], + )); + let mut config = client_config(); + config.ping_interval = Duration::from_secs(3600); + let client = RemoteHostBackend::connect(client_side, hello(), config) + .await + .unwrap(); + let mut events = client.subscribe(); + let mut seen = Vec::new(); + for _ in 0..4 { + let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + seen.push(match event { + HostEvent::ProjectBranch { branch, .. } => branch.unwrap(), + HostEvent::Resync => "resync".to_string(), + other => panic!("unexpected {other:?}"), + }); + } + assert_eq!(seen, vec!["b1", "b2", "resync", "b4"]); + client.close().await; + let _ = tokio::time::timeout(Duration::from_secs(5), peer).await; +} + +#[tokio::test] +async fn a_client_that_stops_draining_is_closed_without_blocking_the_host() { + let host = FakeHost::new(0); + let (client_side, host_side) = in_process_pair(2); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + HostServerConfig { + max_outbound_bytes: 8 * 1024, + lease: Duration::from_secs(3600), + lease_check: Duration::from_millis(20), + ..Default::default() + }, + ); + let serving = tokio::spawn(server.serve(host_side)); + // Hand-rolled client: completes the handshake, then never reads. + let Carrier { + mut sink, + mut stream, + } = client_side; + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + let answer = stream.recv().await.unwrap(); + assert!(matches!( + rpc::decode(&answer.payload).unwrap(), + Message::Response(_) + )); + // The host keeps emitting; the queue fills past the limit and the + // server closes this connection while the publisher never waits. + let publish = tokio::spawn(async move { + for seq in 0..2000u64 { + host.events.publish(HostEvent::ProjectBranch { + project_root: "/p".to_string(), + branch: Some(format!("{seq}{}", "x".repeat(64))), + }); + } + }); + tokio::time::timeout(Duration::from_secs(1), publish) + .await + .expect("publishing never blocks") + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .expect("the server closes the stalled connection") + .unwrap() + .unwrap(); + drop(sink); +} + +#[tokio::test] +async fn a_quiet_peer_loses_its_lease_and_a_pinging_client_keeps_it() { + let host = FakeHost::new(0); + let config = HostServerConfig { + lease: Duration::from_millis(150), + lease_check: Duration::from_millis(20), + ..Default::default() + }; + // Pinging client: stays connected past several leases. + let (client, serving) = connect(Arc::clone(&host), config.clone()).await; + tokio::time::sleep(Duration::from_millis(500)).await; + assert!(!client.is_closed()); + assert!(!serving.is_finished()); + client.close().await; + let _ = tokio::time::timeout(Duration::from_secs(5), serving).await; + + // Quiet peer: handshake only, then silence. + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + config, + ); + let serving = tokio::spawn(server.serve(host_side)); + let Carrier { + mut sink, + mut stream, + } = client_side; + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + stream.recv().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .expect("the lease expires") + .unwrap() + .unwrap(); +} + +/// Complete the handshake by hand on a raw carrier and return its halves. +async fn raw_handshake(client_side: Carrier) -> (Box, Box) { + let Carrier { + mut sink, + mut stream, + } = client_side; + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + let Message::Response(response) = rpc::decode(&stream.recv().await.unwrap().payload).unwrap() + else { + panic!("expected the hello answer"); + }; + assert!(response.error.is_none(), "{:?}", response.error); + (sink, stream) +} + +/// Send one request on a raw carrier and return its response. +async fn raw_call( + sink: &mut Box, + stream: &mut Box, + id: u64, + method: &str, + params: serde_json::Value, +) -> Response { + sink.send(Frame::control( + rpc::encode(&Message::Request(rpc::Request::new(id, method, params))).unwrap(), + )) + .await + .unwrap(); + loop { + let frame = stream.recv().await.expect("a response"); + if let Message::Response(response) = rpc::decode(&frame.payload).unwrap() + && response.id == id + { + return response; + } + } +} + +#[tokio::test] +async fn a_second_hello_is_refused_and_the_hook_runs_once() { + let hellos = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let hook_hellos = Arc::clone(&hellos); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |_| { + hook_hellos.fetch_add(1, Ordering::Relaxed); + })), + ..Default::default() + }; + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(FakeHost::new(0), info(), identity(), config); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let again = raw_call( + &mut sink, + &mut stream, + 2, + "host.hello", + serde_json::to_value(hello()).unwrap(), + ) + .await; + let error = again.error.expect("a second hello is refused"); + assert_eq!(error.code, rpc::code::INVALID_REQUEST); + assert_eq!(error.message, "hello was already sent"); + assert_eq!(hellos.load(Ordering::Relaxed), 1, "the hook ran once"); + // The connection is still usable. + let ping = raw_call( + &mut sink, + &mut stream, + 3, + "host.ping", + serde_json::json!({}), + ) + .await; + assert!(ping.error.is_none()); +} + +#[tokio::test] +async fn integration_setup_is_not_a_wire_method() { + let host = FakeHost::new(0); + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let error = client.setup_integration("x".to_string()).await.unwrap_err(); + assert!(error.contains("on the host itself"), "{error}"); + + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(host, info(), identity(), HostServerConfig::default()); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let response = raw_call( + &mut sink, + &mut stream, + 2, + "integration.setup", + serde_json::json!({"id": "x"}), + ) + .await; + let error = response.error.expect("refused"); + assert_eq!(error.code, rpc::code::METHOD_NOT_FOUND); +} + +#[tokio::test] +async fn watches_are_capped_and_released_when_the_connection_ends() { + let host = FakeHost::new(0); + let (client, serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + client.watch_project_root("/a".to_string()).await.unwrap(); + client.watch_project_root("/a".to_string()).await.unwrap(); + client.watch_project_root("/b".to_string()).await.unwrap(); + client.watch_project_root("/c".to_string()).await.unwrap(); + client.unwatch_project_root("/c".to_string()).await.unwrap(); + client + .unwatch_project_root("/never".to_string()) + .await + .unwrap(); + for index in 0..(MAX_WATCHED_ROOTS - 2) { + client + .watch_project_root(format!("/many/{index}")) + .await + .unwrap(); + } + let error = client + .watch_project_root("/one-too-many".to_string()) + .await + .unwrap_err(); + assert!(error.contains(&MAX_WATCHED_ROOTS.to_string()), "{error}"); + assert_eq!( + host.unwatched.lock().unwrap().as_slice(), + ["/c"], + "an unwatch of a root never watched here is not forwarded" + ); + + client.close().await; + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); + let mut unwatched = host.unwatched.lock().unwrap().clone(); + unwatched.sort(); + let mut expected = vec![ + "/a".to_string(), + "/a".to_string(), + "/b".to_string(), + "/c".to_string(), + ]; + expected.extend((0..(MAX_WATCHED_ROOTS - 2)).map(|index| format!("/many/{index}"))); + expected.sort(); + assert_eq!(unwatched, expected, "every watch was balanced at teardown"); +} + +#[tokio::test] +async fn requests_before_the_handshake_and_unknown_methods_are_refused() { + let host = FakeHost::new(0); + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(host, info(), identity(), HostServerConfig::default()); + let _serving = tokio::spawn(server.serve(host_side)); + let Carrier { + mut sink, + mut stream, + } = client_side; + let early = rpc::Request::new(7, "session.list", serde_json::json!({})); + sink.send(Frame::control( + rpc::encode(&Message::Request(early)).unwrap(), + )) + .await + .unwrap(); + let Message::Response(response) = rpc::decode(&stream.recv().await.unwrap().payload).unwrap() + else { + panic!("expected a response"); + }; + assert_eq!(response.error.unwrap().code, rpc::code::NOT_READY); + + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + stream.recv().await.unwrap(); + let unknown = rpc::Request::new(8, "session.explode", serde_json::json!({})); + sink.send(Frame::control( + rpc::encode(&Message::Request(unknown)).unwrap(), + )) + .await + .unwrap(); + let Message::Response(response) = rpc::decode(&stream.recv().await.unwrap().payload).unwrap() + else { + panic!("expected a response"); + }; + assert_eq!(response.error.unwrap().code, rpc::code::METHOD_NOT_FOUND); +} + +fn send_request(attachments: Vec) -> AgentSendMessageRequest { + AgentSendMessageRequest { + session_id: "s1".to_string(), + text: "look".to_string(), + model: Some("m1".to_string()), + context_limit: None, + mode: None, + vision_capable: true, + steer: false, + queue_id: None, + attachments, + } +} + +fn image(name: &str, len: usize) -> AgentImageUpload { + use base64::Engine as _; + let bytes: Vec = (0..len).map(|i| (i % 253) as u8).collect(); + AgentImageUpload { + name: name.to_string(), + data_url: format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ), + } +} + +#[tokio::test] +async fn a_large_attachment_streams_to_the_host_ahead_of_the_send() { + let host = FakeHost::new(0); + let (client, serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let big = image("screen.png", 6 * 1024 * 1024); + let small = image("icon.png", 300); + let run = client + .send_message(send_request(vec![big.clone(), small.clone()])) + .await + .unwrap(); + assert_eq!(run, "run-for-s1"); + { + let sent = host.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + let request = &sent[0]; + assert_eq!(request.text, "look"); + assert_eq!(request.model.as_deref(), Some("m1")); + assert!(request.vision_capable); + assert_eq!(request.attachments.len(), 2); + assert_eq!(request.attachments[0].name, "screen.png"); + assert_eq!( + request.attachments[0].data_url, big.data_url, + "the host rebuilt the same data URL from the streamed bytes" + ); + assert_eq!(request.attachments[1].name, "icon.png"); + assert_eq!(request.attachments[1].data_url, small.data_url); + } + // A second send reuses nothing: the uploads were consumed. + client + .send_message(send_request(vec![image("again.png", 10)])) + .await + .unwrap(); + assert_eq!( + host.sent.lock().unwrap()[1].attachments[0].name, + "again.png" + ); + client.close().await; + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +/// The next frame on a stream channel, skipping control traffic. +async fn raw_stream_frame(stream: &mut Box) -> Frame { + loop { + let frame = stream.recv().await.expect("a stream frame"); + if frame.channel != CONTROL_CHANNEL { + return frame; + } + } +} + +fn open_upload(channel: u16, upload_id: &str, len: u64) -> Frame { + let open = StreamOpen { + purpose: UPLOAD_PURPOSE.to_string(), + request_id: None, + upload_id: Some(upload_id.to_string()), + mime: Some("image/png".to_string()), + len: Some(len), + }; + Frame { + channel, + kind: FrameKind::Open, + payload: serde_json::to_vec(&open).unwrap().into(), + } +} + +fn refusal(frame: &Frame) -> String { + assert_eq!(frame.kind, FrameKind::Close); + serde_json::from_slice::(&frame.payload) + .unwrap() + .error + .expect("a refusal names its reason") +} + +#[tokio::test] +async fn an_oversized_upload_is_refused_and_the_connection_stays_usable() { + let host = FakeHost::new(0); + // The client refuses before it opens a stream. + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let error = client + .send_message(send_request(vec![image("huge.png", MAX_UPLOAD_BYTES + 1)])) + .await + .unwrap_err(); + assert!(error.contains("too large"), "{error}"); + assert!(host.sent.lock().unwrap().is_empty()); + assert_eq!( + client + .send_message(send_request(vec![image("ok.png", 64)])) + .await + .unwrap(), + "run-for-s1" + ); + + // The host refuses a stream that declares too much, or sends more + // than it declared, and keeps answering afterwards. + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(host, info(), identity(), HostServerConfig::default()); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + sink.send(open_upload( + 1, + "declared-too-much", + MAX_UPLOAD_BYTES as u64 + 1, + )) + .await + .unwrap(); + let reply = raw_stream_frame(&mut stream).await; + assert_eq!(reply.channel, 1); + assert!(refusal(&reply).contains("byte limit")); + + sink.send(open_upload(3, "lied", 4)).await.unwrap(); + sink.send(Frame { + channel: 3, + kind: FrameKind::Data, + payload: vec![0u8; 5].into(), + }) + .await + .unwrap(); + let reply = raw_stream_frame(&mut stream).await; + assert_eq!(reply.channel, 3); + assert!(refusal(&reply).contains("declared")); + + let ping = raw_call( + &mut sink, + &mut stream, + 2, + "host.ping", + serde_json::json!({}), + ) + .await; + assert!(ping.error.is_none()); + let send = raw_call( + &mut sink, + &mut stream, + 3, + "run.send", + serde_json::json!({ + "request": send_request(Vec::new()), + "uploads": [{"uploadId": "lied", "name": "x.png"}] + }), + ) + .await; + let error = send.error.expect("a refused upload is not usable"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); +} + +#[tokio::test] +async fn a_send_naming_an_unknown_upload_or_inline_images_is_invalid_params() { + let host = FakeHost::new(0); + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + HostServerConfig::default(), + ); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let unknown = raw_call( + &mut sink, + &mut stream, + 2, + "run.send", + serde_json::json!({ + "request": send_request(Vec::new()), + "uploads": [{"uploadId": "nope", "name": "x.png"}] + }), + ) + .await; + let error = unknown.error.expect("refused"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); + assert!(error.message.contains("nope"), "{}", error.message); + + let inline = raw_call( + &mut sink, + &mut stream, + 3, + "run.send", + serde_json::json!({ "request": send_request(vec![image("x.png", 8)]) }), + ) + .await; + let error = inline.error.expect("refused"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); + assert!( + error.message.contains("update the client"), + "{}", + error.message + ); + assert!(host.sent.lock().unwrap().is_empty()); + + // A host that does not advertise uploads gets no attachments. + let (client_side, host_side) = in_process_pair(16); + let peer = tokio::spawn(raw_host(host_side, Vec::new())); + let mut config = client_config(); + config.ping_interval = Duration::from_secs(3600); + let client = RemoteHostBackend::connect(client_side, hello(), config) + .await + .unwrap(); + let error = client + .send_message(send_request(vec![image("x.png", 8)])) + .await + .unwrap_err(); + assert!(error.contains("update the host"), "{error}"); + client.close().await; + let _ = tokio::time::timeout(Duration::from_secs(5), peer).await; +} + +#[tokio::test] +async fn uploads_die_with_their_connection() { + let host = FakeHost::new(0); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + HostServerConfig::default(), + ); + let (client_side, host_side) = in_process_pair(8); + let serving = tokio::spawn(Arc::clone(&server).serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + sink.send(open_upload(1, "kept", 3)).await.unwrap(); + sink.send(Frame { + channel: 1, + kind: FrameKind::Data, + payload: b"abc".to_vec().into(), + }) + .await + .unwrap(); + sink.send(Frame { + channel: 1, + kind: FrameKind::Close, + payload: Bytes::new(), + }) + .await + .unwrap(); + let ack = raw_stream_frame(&mut stream).await; + assert_eq!((ack.channel, ack.kind), (1, FrameKind::Close)); + assert!(ack.payload.is_empty(), "the host acknowledged the upload"); + sink.close().await; + drop(stream); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); + + let (client_side, host_side) = in_process_pair(8); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let send = raw_call( + &mut sink, + &mut stream, + 2, + "run.send", + serde_json::json!({ + "request": send_request(Vec::new()), + "uploads": [{"uploadId": "kept", "name": "x.png"}] + }), + ) + .await; + let error = send.error.expect("the upload went with its connection"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); + assert!(host.sent.lock().unwrap().is_empty()); +} diff --git a/apps/maple-agent/crates/maple-remote/tests/manager.rs b/apps/maple-agent/crates/maple-remote/tests/manager.rs new file mode 100644 index 000000000..849d5e9ca --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/manager.rs @@ -0,0 +1,85 @@ +//! The connection manager against a host on a real loopback port: pairing +//! records what the host announced, and the record outlives the +//! connection. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::{FakeHost, client_config, hello, start_host}; +use maple_remote::hosts::HostsStore; +use maple_remote::keys::StaticKey; +use maple_remote::manager::{HostManager, HostManagerEvent, HostStatus, HostVersion}; +use maple_remote::pairing::PairingCode; +use tokio::sync::mpsc; + +async fn next_status( + events: &mut mpsc::UnboundedReceiver, + wanted: impl Fn(&HostStatus) -> bool, +) -> HostStatus { + loop { + let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("an event in time") + .expect("the manager is alive"); + if let HostManagerEvent::Status { status, .. } = event + && wanted(&status) + { + return status; + } + } +} + +#[tokio::test] +async fn pairing_records_the_hosts_version_and_keeps_it_once_offline() { + let fake = FakeHost::new(1); + let host = start_host(Arc::clone(&fake)).await; + let dir = std::env::temp_dir().join(format!("maple-manager-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let device = StaticKey::generate().unwrap(); + let mut client_hello = hello(); + client_hello.device.public_key = device.public_id(); + let store = Arc::new(HostsStore::new(dir.join("hosts.json"))); + let (manager, mut events) = HostManager::new(device, client_hello, store, client_config()); + + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let saved = manager + .pair(&host.address, code, None) + .await + .expect("pairing"); + assert_eq!(saved.id, host.key.public_id()); + // The hello is what `identity()` in the fixture announces. + let expected = HostVersion { + version: "0.1.0".to_string(), + build: Some("abc1234".to_string()), + }; + assert_eq!(saved.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(saved.last_seen_build.as_deref(), Some("abc1234")); + + next_status(&mut events, |status| *status == HostStatus::Online).await; + assert!(manager.is_online(&saved.id)); + assert_eq!(manager.host_version(&saved.id), Some(expected.clone())); + assert_eq!(expected.label(), "0.1.0 (abc1234)"); + let record = manager.store().get(&saved.id).unwrap().unwrap(); + assert_eq!(record.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(record.last_seen_build.as_deref(), Some("abc1234")); + + // The host goes away: the live version goes with the connection, the + // saved record keeps what it last announced. + host.shutdown.cancel(); + drop(host); + next_status(&mut events, |status| { + matches!(status, HostStatus::Offline { .. }) + }) + .await; + assert!(!manager.is_online(&saved.id)); + assert_eq!(manager.host_version(&saved.id), None); + let record = manager.store().get(&saved.id).unwrap().unwrap(); + assert_eq!(record.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(record.last_seen_build.as_deref(), Some("abc1234")); + + manager.shutdown(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/apps/maple-agent/crates/maple-remote/tests/transport.rs b/apps/maple-agent/crates/maple-remote/tests/transport.rs new file mode 100644 index 000000000..7f7b974c7 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/transport.rs @@ -0,0 +1,308 @@ +//! A host listening on a real TCP port with Noise inside WebSocket: a +//! device pairs with a published code, reconnects with the pinned key, +//! and is refused once revoked. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::{FakeHost, client_config, hello, start_host}; +use futures_util::{SinkExt, StreamExt}; +use maple_agent::host::HostBackend; +use maple_remote::client::RemoteHostBackend; +use maple_remote::dial::{ConnectTarget, connect_direct}; +use maple_remote::keys::StaticKey; +use maple_remote::pairing::PairingCode; +use maple_remote::wire::ClientHello; +use tokio_tungstenite::tungstenite::Message; + +fn device_hello(device: &StaticKey) -> ClientHello { + let mut hello = hello(); + hello.device.public_key = device.public_id(); + hello.device.name = "bens-laptop".to_string(); + hello.device.user_id = Some("user-1".to_string()); + hello +} + +#[tokio::test] +async fn a_device_pairs_reconnects_and_is_refused_once_revoked() { + let fake = FakeHost::new(3); + let host = start_host(Arc::clone(&fake)).await; + let device = StaticKey::generate().unwrap(); + + // No code published: pairing is refused and counts as a failure. + let refused = connect_direct( + &host.address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await; + assert!(refused.is_err()); + + // A wrong code against a published one is refused too. + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let wrong = connect_direct( + &host.address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await; + assert!(wrong.is_err()); + assert!( + host.pending.current().is_some(), + "a failed attempt leaves the code pending" + ); + + // The right code pairs, pins the host key, and is consumed. + let dialed = connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .expect("pairing"); + assert_eq!(dialed.host_key, host.key.public_id()); + assert!(host.pending.current().is_none(), "the code is spent"); + assert!(host.devices.is_paired(&device.public_id())); + let client = RemoteHostBackend::connect(dialed.carrier, device_hello(&device), client_config()) + .await + .expect("hello after pairing"); + assert_eq!(client.host_hello().host.id, host.key.public_id()); + let sessions = client.list_sessions(None).await.unwrap(); + assert_eq!(sessions.len(), 2); + // The hello named the device. + tokio::time::sleep(Duration::from_millis(50)).await; + let listed = host.devices.list().unwrap(); + assert_eq!(listed[0].name, "bens-laptop"); + assert_eq!(listed[0].user_id.as_deref(), Some("user-1")); + client.close().await; + + // Reconnect with the pinned key; a wrong pin is refused. + let mut wrong_pin = StaticKey::generate().unwrap().public_id(); + wrong_pin.truncate(43); + assert!( + connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: wrong_pin + } + ) + .await + .is_err() + ); + let dialed = connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .expect("reconnect with the pinned key"); + let client = RemoteHostBackend::connect(dialed.carrier, device_hello(&device), client_config()) + .await + .unwrap(); + let detail = client.load_session("s1".to_string()).await.unwrap(); + assert_eq!(detail.timeline.len(), 3); + + // A hello that claims another device's key is refused. + let stranger = StaticKey::generate().unwrap(); + let dialed = connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .unwrap(); + let error = + RemoteHostBackend::connect(dialed.carrier, device_hello(&stranger), client_config()) + .await + .err() + .expect("mismatched device identity is refused"); + assert!(error.contains("different device"), "{error}"); + + // An unpaired device cannot open a session handshake at all. + assert!( + connect_direct( + &host.address, + &stranger, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .is_err() + ); + + // Revocation refuses the next connection. + host.devices.revoke(&device.public_id()).unwrap(); + assert!( + connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .is_err() + ); + client.close().await; + host.shutdown.cancel(); +} + +#[tokio::test] +async fn repeated_wrong_codes_lock_the_address_out() { + let fake = FakeHost::new(0); + let host = start_host(fake).await; + let device = StaticKey::generate().unwrap(); + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + for _ in 0..3 { + let _ = connect_direct( + &host.address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await; + } + // The right code no longer works from this address within the window. + assert!( + connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .is_err() + ); + host.shutdown.cancel(); +} + +#[tokio::test] +async fn one_code_pairs_exactly_one_of_two_racing_devices() { + let host = start_host(FakeHost::new(0)).await; + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let first = StaticKey::generate().unwrap(); + let second = StaticKey::generate().unwrap(); + let (a, b) = tokio::join!( + connect_direct(&host.address, &first, ConnectTarget::Pair(code.clone())), + connect_direct(&host.address, &second, ConnectTarget::Pair(code.clone())), + ); + assert_eq!( + a.is_ok() as u8 + b.is_ok() as u8, + 1, + "exactly one pairing succeeds: {:?} / {:?}", + a.as_ref().err(), + b.as_ref().err() + ); + assert!(host.pending.current().is_none(), "the code is spent"); + let paired = host.devices.list().unwrap(); + assert_eq!(paired.len(), 1); + let winner = if a.is_ok() { &first } else { &second }; + assert_eq!(paired[0].public_key, winner.public_id()); + host.shutdown.cancel(); +} + +#[tokio::test] +async fn a_revoked_device_reconnecting_does_not_lock_out_pairing_again() { + let host = start_host(FakeHost::new(0)).await; + let device = StaticKey::generate().unwrap(); + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .expect("pairing"); + host.devices.revoke(&device.public_id()).unwrap(); + // More session refusals than the limiter allows pairing failures. + for _ in 0..5 { + assert!( + connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .is_err() + ); + } + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .expect("session refusals did not count against pairing"); + assert!(host.devices.is_paired(&device.public_id())); + host.shutdown.cancel(); +} + +#[tokio::test] +async fn large_frames_cross_the_noise_carrier_in_pieces() { + // Attachments are larger than one Noise message; the carrier must cut + // and reassemble them. + let fake = FakeHost::new(0); + let host = start_host(Arc::clone(&fake)).await; + let device = StaticKey::generate().unwrap(); + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let dialed = connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .unwrap(); + let client = RemoteHostBackend::connect(dialed.carrier, device_hello(&device), client_config()) + .await + .unwrap(); + let bytes = client + .read_image_attachment("s1".to_string(), "a1".to_string()) + .await + .unwrap(); + assert_eq!(bytes, fake.attachment); + let boot = client.bootstrap().await.unwrap(); + assert_eq!(boot.sessions.len(), 2); + client.close().await; + host.shutdown.cancel(); +} + +#[tokio::test] +async fn an_oversized_websocket_message_is_refused_by_both_roles() { + let oversized = || Message::Binary(vec![1u8; 70_000].into()); + + // Host role: a raw peer without the cap sends more than one Noise + // message can hold. The host ends the connection without answering. + let host = start_host(FakeHost::new(0)).await; + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://{}/", host.address)) + .await + .unwrap(); + socket.send(oversized()).await.unwrap(); + let next = tokio::time::timeout(Duration::from_secs(5), socket.next()) + .await + .expect("the host drops the connection"); + assert!( + !matches!(next, Some(Ok(Message::Binary(_)))), + "the host never answers an oversized message: {next:?}" + ); + host.shutdown.cancel(); + + // Client role: a raw host answers the client's first handshake message + // with an oversized one. The dial fails instead of buffering it. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = socket.next().await; + let _ = socket.send(oversized()).await; + while let Some(Ok(_)) = socket.next().await {} + }); + let device = StaticKey::generate().unwrap(); + let error = connect_direct( + &address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await + .err() + .expect("an oversized message fails the dial"); + assert!( + error.to_lowercase().contains("too long") || error.contains("limit"), + "{error}" + ); +} diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md new file mode 100644 index 000000000..60f99023d --- /dev/null +++ b/apps/maple-agent/docs/remote-development.md @@ -0,0 +1,882 @@ +# Remote development + +Status: built. This document describes the feature as it is in the code. +What the plan promised and the code does not do is under "Not built"; the +enclave relay is under "Later: relay". When code and this document +disagree, the code wins; update this document in the same change. + +A Maple host runs the agent runtime and serves it over the network. A Maple +client is the desktop app, which drives its own local host and any number +of remote hosts. Connections are direct, over a LAN or a Tailscale network. +A later release adds a blind relay through the OpenSecret enclave without +changing the protocol above the transport. + +## Goal and scope + +Full parity: a client connected to a remote host gets what the local window +has, through the same `HostBackend` trait the window uses. The host owns +the runtime, the filesystem, the git checkout, the SQLite stores, the +integrations, and project trust. The client owns sign-in, billing, audio, +notifications, and its own display settings. + +Out of scope: the relay, mDNS, a directory browser beyond suggestions, +remote sign-in, cached task lists for offline hosts, terminals, CUA over the +wire, running the client with the local runtime disabled, and connection +probing with automatic switching. + +## Decisions + +These are the answers given during planning, kept as recorded. Where the +build differs in detail, the sections below say what the code does. + +- Full parity. A client connected to a remote host gets everything the local + window has: queue, steering, permissions, questions, integrations, Codex and + Claude Code delegation, context usage, tool summaries, and host settings. + ACP is not the remote protocol. It stays a narrower surface for editors. +- Host forms. A new `serve` subcommand behind a `serve` cargo feature, default + on, headless compatible. The desktop app can also serve, behind an "Allow + remote connections" setting that is off by default. Linux and macOS hosts + both matter. +- Host credentials. The host signs in on its own with `maple-agent login` and + holds its own `auth.json`. There is no remote sign-in. When the host's + refresh token is rejected, clients see "host needs sign-in" and nothing + more. +- Pairing. A high-entropy single-use code shown on the host and typed on the + client. The code is the only proof. No account proof. The host records the + client's claimed user id for display only. +- Encryption. End-to-end between device keys with Noise, so a relay sees only + ciphertext. The relay is not built in this release. +- Client model. One sidebar merges sessions from the local host and every + connected remote host, with a host filter. New tasks go to the host chosen + in the sidebar. +- Fan-out. Every connected client sees every event. Permission prompts and + questions go to all clients. The first answer wins. +- Discovery. Manual address entry. No mDNS. The desktop app shows its listen + addresses and pairing code in Settings. +- Project roots. Text entry plus the host's recent roots plus host-side + directory suggestions. No native folder picker on any host, so local + and remote selection are the same dialog. +- Session defaults are per host. Default permission mode, default web, + harness instructions, and default model move from app settings into the + host's per-account config. +- Local window. The local window calls the runtime in process and never loops + through the protocol. +- Delivery. Host-assigned sequences, bounded snapshots, paged catch-up. + A client must never miss a message. +- Slow clients. The host closes a socket whose outbound queue would cross the + limit. It never blocks the run and never touches other clients. +- Reserved. A generic binary stream channel type in the framing so a PTY can + be added later without a protocol change. CUA stays host-local. + +## Glossary + +Use these words for these concepts in this document and in new UI copy. +The transport code says "peer" for the other end of a connection, and UI +copy says "this machine" for the computer the app runs on; neither names a +host or a device. + +| Term | Meaning | +| --- | --- | +| Host | A process that runs the agent runtime and accepts client connections. Every app instance is its own local host. Identified by its static Noise public key; the local host's id is `local`. | +| Client | The desktop app acting as a consumer of a host. | +| Device | A client identity, one static Noise key pair. One person may have several devices. | +| Connection | One way to reach a host. Today the only kind is `direct`, an address. A host has one or more connections. | +| Pairing | The one-time exchange that gives a device and a host each other's static key. | +| Session | A Maple task with its timeline, owned by exactly one host. The UI says "task". | +| Generation | A UUID a host mints when its process starts. Every event sequence is scoped to it. | + +## Architecture + +### The backend seam + +`app/src/backend.rs` keeps the account-level concerns that never go over +the wire: sign-in and OAuth, billing, audio transcription and speech, update +checks, opening URLs, and desktop notifications. These use the client's own +OpenSecret session. + +Everything a client drives on a host is the `HostBackend` trait in +`crates/maple-agent/src/host/mod.rs`, with two implementations: + +- `LocalHostBackend` (`crates/maple-agent/src/host/local/`) wraps + `AgentRuntimeHandle` in process and owns the host-side pieces the UI must + not reach into: the filesystem, the git dir, and the account's SQLite + stores. `AgentBackend::local_host` hands out one per account. +- `RemoteHostBackend` (`crates/maple-remote/src/client.rs`) speaks the wire + to a `HostServer`. One instance is one connection; when the connection + ends the instance is dead and its owner reconnects with a fresh one. + +Hosts push `HostEvent`s: `Service` (a runtime event), `ProjectBranch`, and +`Resync`. `HostEventHub` fans one host's events out to every subscriber; it +is the runtime's event sink for the local host, and `HostServer` subscribes +to it for every connection. `LocalHostBackend` and `HostServer` are +siblings over the same runtime handle, not layers. + +`HostServer` (`crates/maple-remote/src/server.rs`) serves any number of +connections. Requests are dispatched by the domain prefix of the method to +one controller each: `host`, `project`, `session`, `run`, `model`, +`integration`. A controller is a plain `match` over its domain's request +enum. + +There is no router type. The chat screen (`app/src/ui/chat/hosts.rs`) keeps +one `ChatHost` entry per known host, maps every task id to the host that +owns it (`session_hosts`), and points its single `host` handle at the host +new tasks target. A call about a task goes to `backend_for(session_id)`; +every other call goes to the target. The local host is named +"This computer". + +### What moved to the host + +Four places in the UI used to touch the host filesystem directly. Each is +now a host method or a pushed event. + +| Before | Now | +| --- | --- | +| Native folder picker returned a local path; `is_dir()` ran in the UI process. | `HostBackend::select_project_root(path)` registers the root on the host: a leading `~` expands against the host's home directory (a typed path arrives as written on the client), the path is canonicalized, and a non-directory is an error. `suggest_directories(query)` answers from the host: an empty query lists the home directory, `~` means home, hidden directories appear only for a dot prefix, at most 50 entries. Recent roots come from the host. | +| Git branch read and `notify` watcher ran in the UI process. | The host owns one watcher per root, shared and reference-counted across the clients that asked for it, and pushes `HostEvent::ProjectBranch` when a watch starts and whenever `HEAD` changes. Access-only filesystem events are dropped; there is no other rate limit. | +| UI opened `sessions.db` every second for the context ring. | The client polls `HostBackend::context_usage(session, model)`: while a run is active on the task on screen, a poller ticks every 5 s and re-reads only when the task's timeline changed since the last tick. Nothing is pushed. | +| UI opened `tool_summaries.db` read-write. | `HostBackend::tool_summaries(session)` and `store_tool_summary`. | + +Image attachments a task already holds are read with +`read_image_attachment` and travel on a binary stream from the host. +Images the user attaches to a new message travel on a binary stream from +the client ahead of `run.send`, which names them by upload id (see +"Streams and credit"). Neither direction is bounded by the control +frame limit. + +### Local-only capabilities + +Integration setup runs where the window is. `RemoteHostBackend:: +setup_integration` answers "set up integrations on the host itself" without +a wire call; there is no `integration.setup` method. Desktop notifications +are the client's: it raises them for the tasks it shows when notifications +are on and the window is not focused, and nothing in that path asks which +host owns the task. CUA stays host-local; the client learns nothing about a +host's CUA status. + +### Integrations on a remote host + +Claude Code, Codex, custom MCP servers, the shell tool, and project trust +run where the runtime runs. A remote client sees their output and answers +their permission cards. The enabled toggles and the MCP server list are +host-side (`integration.list`, `integration.set_enabled`, +`integration.list_mcp`, `integration.save_mcp`), edited from the client +through the host selector that Settings shows on host-scoped sections once +more than one host is connected. + +A session a remote client creates goes through the host's +`create_session`, the same call the window makes, so it is a desktop +session like the window's. Sessions created by ACP callers stay out of +every client's task list. + +## Protocol + +The protocol lives in `crates/maple-remote`. Bottom up: `carrier`, `noise`, +`listen`, `dial`, `net`; `keys`, `pairing`, `devices`; `hosts`, `manager`; +`frame`, `rpc`, `streams`, `outbound`; `wire`; `server`; `client`. It +depends on `crates/maple-agent` for domain types and never on `app`. + +### Carrier and framing + +A `Carrier` is a struct of two boxed halves, a `FrameSink` and a +`FrameStream`. `carrier::in_process_pair` connects two in one process for +tests. The network carrier is a plain `ws://` WebSocket with Noise inside: +`listen::serve_listener` accepts connections for one `HostServer`, +`dial::connect_direct` opens one for a client, and `net` holds what both +share. There is no connector trait; the relay is a second dial function +later. + +Every WebSocket binary message is exactly one Noise transport message, and +both roles cap WebSocket messages at 65535 bytes, the Noise maximum. A +frame is cut into pieces of at most 65535 - 16 - 1 bytes; each piece +carries one continuation byte (`1` more follows, `0` last) before the frame +bytes. A peer that reassembles past the largest control frame plus its +header is cut off. + +The plaintext of a frame is: + +``` +[channel: u16 BE][kind: u8][payload] +``` + +Channel 0 is control and carries one JSON-RPC message per `Data` frame. +Channels 1 and up are binary streams. Kinds are `Open` (0), `Data` (1), +`Close` (2), and `Credit` (3). The control frame limit is 4 MiB; anything +larger belongs on a stream or must be paged. A stream data frame is at most +256 KiB. A short header, an unknown kind, or an oversized payload closes +the connection. + +Every frame a side sends goes through one byte-bounded outbound queue, +64 MiB by default. A frame that would push the count past the limit is +refused and marks the connection for closing: the peer has stopped +draining, and the host never waits on a client. Each connection's +forwarder serializes its own copy of a broadcast; nothing is serialized +once and shared. + +### Control channel and methods + +Control messages are JSON-RPC 2.0 with numeric ids. Requests get exactly +one response. The `event` notification carries host events from host to +client. The keepalive is the `host.ping` request. The host answers requests +concurrently, each on its own task, so a slow call never delays the ping. + +Error codes: the JSON-RPC reserved `INVALID_REQUEST`, `METHOD_NOT_FOUND`, +and `INVALID_PARAMS`, plus `HANDSHAKE_REFUSED` (-32000, the connection +closes after the answer), `NOT_READY` (-32001, `host.hello` has not been +sent), and `HOST_ERROR` (-32002, the host's `HostBackend` returned an +error; the message is the user-facing text). There is no parse error: a +control frame the host cannot decode closes the connection, because +nothing in it can be trusted to carry an id. The client drops an +undecodable control frame and logs it. + +Methods are namespaced by domain and mirror `HostBackend`. The request +enums in `crates/maple-remote/src/wire.rs` are the source of truth; each is +a serde enum tagged by `method` with the variant's fields as camelCase +`params`, and the `*_METHODS` constants list every name (a test checks +them against the enums): + +``` +host.hello, host.ping, host.bootstrap, host.start_runtime, +host.stop_runtime, host.session_defaults, host.set_session_defaults, +host.save_default_model, host.usage_summary, host.context_usage, +host.tool_summaries, host.store_tool_summary +project.recent_roots, project.select_root, project.remove_root, +project.suggest_directories, project.watch, project.unwatch, +project.trust, project.set_trust +session.list, session.create, session.load, session.timeline, +session.rename, session.set_state, session.delete, session.compact, +session.subagents, +session.cancel_external_agent, session.set_permission_mode, +session.set_web_enabled, session.read_attachment +run.send, run.cancel, run.cancel_queued, run.begin_queued_edit, +run.end_queued_edit, run.answer_question, run.permission_respond, +run.ask_side_question, run.summarize_tool_call, run.summarize_thinking +model.list, model.supports_vision, model.slash_commands, +model.resolve_slash_command +integration.list_session_mcp, integration.set_session_mcp, +integration.list_mcp, integration.save_mcp, integration.list, +integration.set_enabled +``` + +There are no response enums. A response is the `HostBackend` return type +serialized, with four exceptions: `host.bootstrap` answers a +`BootstrapSnapshot` (the bootstrap with the newest task's timeline +stripped, plus `latestTimelineLen`), `session.load` answers a +`SessionSnapshot` (the detail with an empty timeline, plus `timelineLen`), +`session.timeline` answers a `TimelinePage` (`items`, `hasMore`), and +`session.read_attachment` answers an `AttachmentHandle` (`stream`, `len`). +`host.ping` answers `{}`. There are no `device.*` methods. + +Compatibility rules for everything in `wire`: + +- Schemas are append-only. New params are `Option` with a serde default; + unknown fields are ignored on both sides; a field that stops being sent + stays accepted. Enums are not `#[non_exhaustive]`. +- Every compatibility shim carries a dated tag: + `// COMPAT(name): added in vX.Y, remove after YYYY-MM-DD once host floor >= vX.Y.` + `rg 'COMPAT\('` is the cleanup backlog. +- `protocol` is a tripwire, bumped only for a change no feature flag can + express. Real evolution goes through the `features` bags in the + handshake. Today both sides send the same table and neither gates a + call on the other's flags; the client checks only the protocol version. + +`AgentServiceEvent`, `AgentRunEvent`, and the request and response types +in `crates/maple-agent/src/agent/types.rs` carry `Serialize` and +`Deserialize`. That is the wire contract; review those types with that in +mind. + +### Streams and credit + +Either side sends streams. The side that sends the bytes opens a stream +with an `Open` frame on a free channel: clients open odd channels, hosts +even ones, so the two directions never collide. The `Open` payload is +JSON: `purpose` (`attachment` or `upload`), `requestId` (for an +attachment, the JSON-RPC request the stream answers), `uploadId` and +`mime` (for an upload), and `len`, required for uploads. The receiver +starts the sender with 16 frames of credit and grants 8 more each time +it has consumed 8, so one slow transfer never fills the connection's +outbound queue. The sender ends the stream with an empty `Close`, or +early with `Close { "error": "..." }`; a sender dropped mid-stream sends +`Close { "error": "the sender gave up" }` so the peer frees the slot. The +receiver answers every stream with its own `Close`: empty to acknowledge +it, or with an error to refuse it at the `Open`, mid-stream, or when it +ended short of `len`. The receiver collects the bytes whole and +preallocates at most 64 MiB on the announced length. + +An attachment read works like this: the host reads the bytes, opens the +stream with the request id, then answers the RPC with the channel and +length. The open frame precedes the answer on the same ordered carrier, so +the client pairs the collector with the answer by request id and waits for +it. A failed read is an RPC error before any stream opens. When the RPC +fails or the wait runs out, the client abandons the request and drops any +collector opened for it, so late frames are discarded. + +#### Uploads + +An image the user attaches to a message travels before the message. For +each attachment the client mints an upload id, opens a stream with +`purpose: "upload"`, the id, the mime type, and the byte length, sends +the bytes under credit control, closes, and waits for the host's +acknowledging `Close`. It then sends `run.send` with `{ request, +uploads: [{ uploadId, name }] }`; the request's own `attachments` must be +empty on the wire. The host rebuilds each image from the stored bytes +and the mime type before calling the runtime. A request that names an +unknown or incomplete id is `INVALID_PARAMS`, and a request naming +several ids consumes all of them or none. + +The host keeps at most 4 uploads in flight and 16 completed but not yet +referenced per connection, dropping the oldest, and at most 10 MiB each; +the client refuses larger images before opening a stream. Upload ids and +mime types are short printable ASCII, and a mime type may not contain +`,` or `;`, so it cannot alter the rebuilt data URL. Every upload dies +with its connection. Both sides advertise `uploadStreams`; a host without +it makes the client refuse attachments with "update the host", and a +client without it that sends inline images is refused with "update the +client". + +### Sequence and resync + +`HostServer` subscribes a connection to the host's events before it +answers the hello, so nothing is lost between the two; the forwarder holds +events until the client is ready. Every event on a connection carries one +monotonic `seq`, starting after the `seq` in the host's hello (0). The +client accepts `seq == expected` and treats anything else as a gap: it +publishes `HostEvent::Resync`, then the event. On `Resync` the UI re-reads +the task list and reloads the task on screen. A reconnect is a new +connection and a new `RemoteHostBackend`, and always resyncs. + +The host keeps no event log and no per-entity cursors. `session.load` +builds a snapshot, keeps it for the connection (at most 8, least recently +paged first), and `session.timeline` pages it by item count and by bytes +(200 items or 1 MiB per page, at least one item always fits) until +`hasMore` is false. A page for a task the connection never loaded loads it +first. The bootstrap's newest task is paged the same way. Live events +emitted while a snapshot loads are also in the snapshot; applying them +again is idempotent because timeline items are keyed by id. + +Each socket is independent: there is no logical client session across +sockets, and nothing survives a dropped socket. Permission answers go +straight to the runtime, which removes the pending request on the first +answer; a second answer to the same request fails as a host error ("No +pending Agent Mode permission request found"). Queue edits and steering +are last write wins with the host authoritative. + +### Handshakes + +Two Noise handshakes, both with the `snow` crate's default resolver: +pairing runs `Noise_XXpsk3_25519_ChaChaPoly_BLAKE2s` with the one-time code +as the pre-shared key, and every later connection runs +`Noise_IK_25519_ChaChaPoly_BLAKE2s` with the host's pinned static key. The +first byte of the client's first message names the handshake (`1` pair, +`2` session) and selects the prologue (`maple-remote-v1/pair` or +`maple-remote-v1/session`), so a relay cannot swap one for the other. In +the pairing pattern the client sends the last handshake message, so the +host sends one empty transport message once it has spent the code and +recorded the device; the client trusts nothing until it decrypts it. In +the session pattern the client's static key arrives in the first message, +and the host refuses an unpaired key before answering anything. The client +refuses a host whose static key does not match the pinned key. + +After the Noise handshake the client sends `host.hello` as its first +request, and the host answers with its own hello: + +```json +{ + "protocol": 1, + "appVersion": "0.1.0", + "build": "63bcff5c", + "pcrEnvironment": "Production", + "features": { "timelinePaging": true, "attachmentStreams": true, + "uploadStreams": true, "ping": true }, + "device": { "publicKey": "...", "name": "bens-laptop", "userId": "..." } +} +``` + +```json +{ + "protocol": 1, + "appVersion": "0.1.0", + "build": "63bcff5c", + "pcrEnvironment": "Production", + "generation": "uuid", + "seq": 0, + "features": { "timelinePaging": true, "attachmentStreams": true, + "uploadStreams": true, "ping": true }, + "host": { "id": "", "name": "workstation", "userId": "..." } +} +``` + +`build` is the git revision `build.rs` baked into the binary (`abc1234`, +or `abc1234-dirty`), so two builds of one package version can be told +apart; it is optional and absent from a build outside a git checkout or +from an older peer. The host logs the client's name, key, version, and +build on connect ("client bens-laptop (...) connected running maple-agent +0.1.0 (63bcff5c)"); the client keeps the host's for the Settings screen. + +The host refuses the hello with `HANDSHAKE_REFUSED` and closes when the +protocol differs, when `pcrEnvironment` differs (two binaries built for +different enclaves cannot share a backend), or when the hello names a +different device key than the handshake proved. Any other method before +the hello gets `NOT_READY`; a second hello gets `INVALID_REQUEST`. The +device name is cleaned before it reaches a log or the device list: control +characters removed, at most 64 characters. `HostServerConfig:: +on_client_hello` runs once per connection after the hello is accepted; the +app's hook records the device's claimed name, user id, and last-seen time. + +### Liveness + +Four budgets. None is inferred from another. + +| Budget | Value | +| --- | --- | +| Connect | 15 s: the host gives the WebSocket and Noise handshakes one deadline; the client gives the WebSocket connect, the Noise handshake, and the hello answer 15 s each | +| Application ping | Client sends `host.ping` every 10 s with a 15 s timeout; 2 consecutive misses close the connection | +| Host lease | 45 s, running from the moment the connection is created, renewed by any inbound frame, checked every 10 s, close on expiry | +| RPC | 60 s default; 90 s for `host.start_runtime`. A timeout is an operation failure, never proof the socket is dead | + +A closing host connection waits up to 2 s for its queued frames (a +refusal, an error answer) to reach the peer before the writer is +abandoned, then releases every project watch the connection placed. + +Reconnect uses full-jitter exponential backoff: an exponential delay from +1 s to a 30 s cap, jittered between half and all of it, reset on a +successful connection. There is no foreground probe. + +### Pairing + +1. On the host, `maple-agent serve pair` or the desktop app's "Generate + pairing code" button writes a pending pairing record to + `/remote/accounts//pending_pairing.json` at mode + 0600 and shows the code. The running listener reads the file on every + incoming connection, so a running host needs no restart. A record is + valid for 5 minutes and is spent by the first pairing that completes; + an expired record is removed when it is next read. +2. The code is 80 bits of randomness shown as sixteen Crockford base32 + characters in groups of four (`XXXX-XXXX-XXXX-XXXX`). Parsing accepts + any case, ignores dashes and spaces, and maps the usual confusables. + The pre-shared key is SHA-256 over a domain tag and the code. +3. The client dials the address with the code and runs the pairing + handshake. Both sides learn each other's static key in it. +4. Before confirming, the host spends the code (a code already spent or + replaced refuses the pairing, so of two devices racing on one code + exactly one succeeds) and records the device in + `/remote/accounts//devices.json`: public key, the + name "new device" until the hello names it, claimed user id, paired + at, last seen. The client saves the host in its per-account + `hosts.json`: public key, name (the host's announced name unless the + user gave one), the address, paired at. Pairing again with a known key + merges into the saved record. +5. Failed pairing handshakes are counted per source address: 5 in 10 + minutes lock the address out of pairing until the window passes, with + at most 1024 addresses tracked (past that the address with the oldest + failure is forgotten). Only pairing-mode failures count, so a revoked + device that keeps retrying a session handshake cannot lock its address + out of pairing again. A locked-out address is offered no pre-shared + key; its session handshakes still work. Failures never delete the + pending record. + +Hosting serves one account's runtime, so devices and codes belong to the +account that is hosting: a device paired while account A was signed in is +not admitted by a host of account B, and a code published for A never +pairs a device into B. `serve pair` and `serve devices` resolve the account +from the saved sign-in and refuse to run without one. The host key and the +lock stay per machine. + +Revocation is an edit to the device file. The listener checks every 10 s +whether each connected device is still paired and drops a revoked device's +connection at the next check. A revoked device's next dial is refused in +the handshake. + +### Devices and hosts stores + +Keys are X25519 pairs generated on first use and stored as +`{ "private": "...", "public": "..." }` (base64url, no padding) at mode +0600. Keys never appear in logs; `Debug` on a key shows only the public +half, and `Debug` on a code or pending record hides the code. + +The client's `hosts.json` is one file per account: + +```json +{ + "hosts": [ + { + "id": "", + "name": "workstation", + "connections": [ + { "kind": "direct", "address": "100.64.0.7:7130" }, + { "kind": "direct", "address": "192.168.1.20:7130" } + ], + "pairedAtMs": 0, + "lastSeenVersion": "0.1.0", + "lastSeenBuild": "63bcff5c" + } + ] +} +``` + +Adding a connection whose host presents an already-known public key merges +into that host. `lastSeenVersion` and `lastSeenBuild` are what the host +announced at its most recent hello and are rewritten on every connect; +both are optional, so a file from before they existed still loads. Loading +salvages per entry: a malformed connection is dropped, not the host, and a +malformed host is dropped, not the file. + +The host's `devices.json` is `{ "devices": [ { "publicKey", "name", +"userId", "pairedAtMs", "lastSeenMs" } ] }`. Revoking by name is refused +when several devices share it; revoke by key. The pending record is +`{ "code", "createdMs", "expiresMs" }`. + +## Host role + +### The serve command + +``` +maple-agent serve Listen for paired clients. +maple-agent serve pair Publish a one-time pairing code. +maple-agent serve devices list Paired devices. +maple-agent serve devices revoke DEV Forget a device by key or name. + +--listen ADDR:PORT bind address (default 0.0.0.0:7130, env MAPLE_SERVE_LISTEN) +--name NAME host name clients show (default: hostname, env MAPLE_SERVE_NAME) +``` + +`serve` binds every interface by default, because pairing is the gate; +give one address (a Tailscale IP) to narrow it. The default port is 7130, +not 8080, which the proxy mode uses. A port that cannot be bound is an +error; nothing else is tried. The host name comes from `HOSTNAME` in the +environment, else `gethostname`, else "maple". + +`serve` requires a saved sign-in and exits with a message otherwise. A +saved sign-in the server rejects exits with a message to run `login` +again. A server that cannot be reached at start does not: the host serves +with the saved sign-in, requests fail until it goes through, and the +sign-in is retried behind them with growing pauses (5 s, doubling to +5 minutes), so a unit that starts before the network recovers on its own. +Session defaults an older version kept in `settings.json` are adopted into +the account config by every mode that binds an account, including the +window after a sign-in. + +`serve pair` prints the code on stdout and guidance on stderr, including +the running host's name and address when one runs; it learns that from +`serve.json`, read only while the hosting lock is held, so a crashed host's +leftover state is ignored. `serve devices revoke` accepts a public key or +a name. + +`serve` runs under systemd: it stops on SIGTERM as well as Ctrl-C, and when +`NOTIFY_SOCKET` is set (`Type=notify`) it sends `READY=1` once the port is +bound and `STOPPING=1` on the way out. Stopping waits for the listener and +its connections to end before releasing the lock, so a restart right after +can bind. A user unit: + +```ini +[Unit] +Description=Maple host +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +NotifyAccess=main +ExecStart=%h/.local/bin/maple-agent serve --listen 100.64.0.7:7130 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 + +[Install] +WantedBy=default.target +``` + +Run `maple-agent login` once as that user first, then +`systemctl --user enable --now maple-serve`; `loginctl enable-linger` keeps +it up after logout. + +### Desktop hosting + +`app/src/remote/` holds both roles: `host.rs` the host role, `client.rs` +the connection manager for saved hosts, and `mod.rs` the files both keep +under `/remote/`. `Hosting::start` takes the data-root lock, +loads the host key, binds, writes `serve.json`, and serves the account's +local host on the backend runtime. The `serve` command runs it in the +foreground; the window runs it behind the "Allow remote connections" +setting, off by default. The command and the window share the host key, +the lock, and, for one account, the device list and the pending code, so +only one of them serves at a time; the other reports who holds the root. +The lock is a file lock, so a crashed host leaves nothing that blocks the +next start. + +The window's host role (`HostingController`) has four states: off, +starting, listening, and failed. Starting runs on the backend runtime, +never the UI thread; a stop that arrives meanwhile wins. The setting +persists as on only once the host listens, so a start that failed (the +port taken, another host on the root) does not come back at the next +launch; the failure shows in place. Turning the setting off stops hosting +at once. At launch, hosting starts when the setting is on. + +### Files + +| Path | Owner | Contents | +| --- | --- | --- | +| `/remote/host_key.json` | host | This machine's static Noise key as a host, 0600 | +| `/remote/device_key.json` | client | This machine's static Noise key as a client device, 0600 | +| `/remote/serve.lock`, `serve.json` | host | The running host's lock and its listen address, name, and key | +| `/remote/accounts//devices.json` | host | Devices paired into this account on this host | +| `/remote/accounts//pending_pairing.json` | host | The pairing code published for this account, until used or expired, 0600 | +| `/agent/accounts//hosts.json` | client | Hosts this account paired with: key, name, addresses | +| `/agent/accounts//config.json` | host | Existing `AgentConfig`, including session defaults | +| `/settings.json` | client | Client settings, including `allow_remote_connections`, `last_task_host`, and per-host UI state under `hosts` | + +`` is the SHA-256 of the account's user id. The `remote/` +directories are created owner-only. + +## Client role + +### Connection manager + +`maple_remote::manager::HostManager` runs one connector task per saved +host on the backend runtime. A connector dials the host's connections in +order and uses the first that completes a handshake, hands the UI a +connected `RemoteHostBackend`, forwards the host's events, and reconnects +with the backoff above when the connection ends. Pairing dials with the +code, saves the host, and starts its connector on the connection the +pairing opened. Everything the UI needs arrives as `HostManagerEvent`s on +one channel: `Status { host, name, status, backend }` (`backend` is +present exactly when the status is `Online`), `Event { host, event }`, and +`HostsChanged(saved hosts)`. The desktop shell pumps that channel into the +chat screen in batches of up to 256, like the local host's events. + +The status states are `Connecting`, `Online`, and `Offline { reason }`. +Removing a host cancels its connector and reports +`Offline { reason: "removed" }`. A replaced or removed connector says +nothing more once it is superseded. The manager also answers +`is_online(id)` and `host_version(id)` (the version and build the live +connection's hello announced, `None` while offline) for the settings +screen, and writes that version to the saved host on every successful +hello. `rename` exists on it and the store, but no UI calls it. + +### Client settings + +`/settings.json` stays client-only: theme, fonts, vim modes, +shortcut overrides, notifications, reduce motion, window state, TTS voice +and speed, and the tool details and tool summaries display defaults. It +also holds `allow_remote_connections` (default false), `last_task_host` +(the remote host the last new task was created on; absent when it was the +local host), and `hosts`, a map from host id to `HostUiState`: pinned +tasks, settled and unsettled tasks, and project display names keyed by +path on that host. + +Per host, in the host's per-account `AgentConfig`: default permission +mode, default web enabled, harness instructions, and the default model. +`HostSessionDefaults` carries all four; `set_session_defaults` writes the +first three and leaves `default_model` alone, so a stale settings snapshot +cannot put an old model back; the chat screen saves the model through +`save_default_model`. Values an older app kept in `settings.json` migrate +once into the local host's config; values the config already holds win. + +### Sidebar and tasks + +The sidebar merges tasks across hosts, one row per task, sorted as before. +`HostBootstrap` reads a host's saved project root, task list, recent +roots, newest task, and session defaults in one call; a remote host is +read that way when it connects, and its runtime is started. When more than +one host is known, each row shows the host name after the project name +(`project · host`), and the project switcher menu gains a host block above +the project rows: every host, then "All hosts". Offline hosts stay listed, +grayed, so a host that dropped is still visible; their tasks leave the +list until the host is back (the task on screen stays readable), and a +filter on an offline host is refused with a notice. New tasks go to the +host the filter names, else the selected task's host, else the local host. +"New Task" shows an empty draft and creates nothing; the task is created +on the target host when the first message is sent, carrying the draft's +mode, model, web access, and integration toggles, so the host or project +can still change before then and a draft that sends nothing leaves no +row behind. + +When a host drops after having been online, a notice names it once; the +reconnect attempts that follow report nothing more until it is back. An +answer from a connection that has since dropped or been replaced is +stale and is discarded. + +### Project selection + +Choosing a project is one dialog for every host (`app/src/ui/chat/ +picker.rs`): a search box over the target host's recent projects and its +directory suggestions, and an "Open this path" row when the text starts +with `/` or `~`. Arrows, Enter, and Escape drive it. There is no native +folder picker on any host. + +### Host chip and restore + +With a task open, the header names the host that task runs on as a +badge with a status dot; a task never moves, so there is nothing to +switch. On the new-task screen the same place holds a chip that names +the host new tasks run on and switches it from a dropdown; the sidebar +filter and the selected task move the target too. Both appear only when +more than one host is known. +Switching the target adopts that host's project root, recent roots, and +session defaults; the draft on screen follows, since no task exists until +its first message is sent. + +The host the last new task ran on is saved in the client settings and is +the target again at the next launch: startup holds the local auto-select +until that host connects, then makes it the target if nothing was chosen +meanwhile, shows its saved project, and opens its latest task with its +stored tool summaries. A remembered host that reports offline, is no +longer saved, or fails its bootstrap releases startup to the local +auto-select. + +### Settings + +Settings has one Hosts pane for both roles. For the client role it pairs +(address, code, optional name) and lists the saved hosts with their +connection state and a remove action; there is no rename UI. Each row +shows the version and build the host announced, as `0.1.0 (63bcff5c)` +while online and `last seen 0.1.0 (63bcff5c)` while offline, and a line +comparing it with this app: "Behind this app; update the host" when the +host's version is lower, "Different build from this app" when only the +build differs, "Newer than this app; update this app" when it is higher, +and "Update available: " when the update check found a release +newer than the host. The rows are computed when the list or a host's +state changes; while the pane is shown it polls the manager once a +second and re-renders only when a row changed. For the host role it +shows the "Allow remote connections" toggle and its state: +"Not listening", "Starting", the bound socket, or, when the host binds +every interface, the port with a note to use the machine's LAN or +Tailscale address. "Generate pairing code" works only while listening; the +code stays on screen until the host consumed it (the device list is then +re-read) or it expired. Below that, the paired devices with a revoke +action. Device and pairing files are read and written off the UI thread. + +Host-scoped sections (session defaults, system prompt, integrations, MCP +servers, usage) get a "Host" selector once more than one host is +connected; choosing a host re-reads everything the section shows from +that host. + +## Authority and trust + +A paired device has the same reach as the desktop window on that host. +Project trust is enforced host-side. Sessions created by ACP callers stay +hidden from clients. The host records the claimed user id for display +only; the pairing code is the whole proof, and the pinned static keys are +the identity afterwards. + +The host never logs access or refresh tokens, plaintext prompts, or +credential-bearing environments, per the repository security rules. +Pairing codes and private keys are never logged. + +## Limits and constants + +| Constant | Value | Where | +| --- | --- | --- | +| Control frame | 4 MiB | `frame::MAX_CONTROL_FRAME_BYTES` | +| Stream data frame | 256 KiB | `frame::MAX_STREAM_FRAME_BYTES` | +| WebSocket message | 65535 bytes | `net::MAX_WEBSOCKET_MESSAGE_BYTES` | +| Outbound queue | 64 MiB | `outbound::DEFAULT_MAX_OUTBOUND_BYTES` | +| Stream credit | 16 initial, 8 refill | `streams::INITIAL_CREDIT`, `CREDIT_REFILL` | +| Kept snapshots per connection | 8 | `server::MAX_KEPT_SNAPSHOTS` | +| Watched roots per connection | 64 | `server::MAX_WATCHED_ROOTS` | +| Timeline page | 200 items, 1 MiB | `HostServerConfig` | +| Close flush | 2 s | `server::CLOSE_FLUSH_TIMEOUT` | +| Lease | 45 s, checked every 10 s | `HostServerConfig` | +| Handshake / connect | 15 s | `net::HANDSHAKE_TIMEOUT`, `ClientConfig::connect_timeout` | +| Ping | every 10 s, 15 s timeout, 2 misses | `ClientConfig` | +| RPC | 60 s; 90 s for runtime start | `ClientConfig` | +| Backoff | 1 s to 30 s, full jitter | `manager` | +| Pairing code | 16 chars, 80 bits, 5 min | `pairing` | +| Pairing limiter | 5 failures per 10 min, 1024 addresses | `pairing::PairingLimiter` | +| Revocation check | every 10 s | `listen` | +| Device name | 64 chars, no control characters | `devices::MAX_DEVICE_NAME_CHARS` | +| Directory suggestions | 50 | `directories::SUGGESTION_LIMIT` | +| Context usage poll | every 5 s while a run is active | `ui/chat/mod.rs` | + +## Testing + +Unit tests sit beside each module in `crates/maple-remote/src/`: frame +round trips and refusals, the WebSocket cap, Noise reassembly limits, +outbound overflow and oversized frames, stream credit and abandonment, +pairing codes and the limiter, the device and hosts stores, request +decoding and the method lists, and manager removal and backoff. + +Integration tests in `crates/maple-remote/tests/` run a `HostServer` over +a scripted `FakeHost`: + +- `loopback.rs`, over the in-process carrier: handshake refusal for a + different environment and protocol; snapshots page completely and + calls round-trip; events arrive in order through the hub; attachments + stream whole and a missing one is an error; a sequence gap publishes a + resync before the event; a client that stops draining is closed without + blocking the host; a quiet peer loses its lease and a pinging client + keeps it; a second hello is refused and the hook runs once; integration + setup is not a wire method; watches are capped and released when the + connection ends; requests before the handshake and unknown methods are + refused; a large attachment streams to the host ahead of the send; an + oversized upload is refused and the connection stays usable; a send + naming an unknown upload or carrying inline images is invalid params; + uploads die with their connection. +- `transport.rs`, over a real listener with Noise: a device pairs, + reconnects, and is refused once revoked (on its next dial); repeated + wrong codes lock the address out; one code pairs exactly one of two + racing devices; a revoked device reconnecting does not lock out pairing + again; large frames cross the Noise carrier in pieces; an oversized + WebSocket message is refused by both roles. +- `manager.rs`, the connection manager over the same listener: pairing + records the host's version and build, `host_version` answers them while + online and `None` once the host is gone, and the saved host keeps them. + +Not covered: two clients answering one permission prompt, a revoked +device's live connection being dropped mid-session, and the desktop +app's host and client roles end to end. + +## Not built + +The plan promised these; the code does not do them. + +- A pending pairing record deleted after a failure window. Failures only + feed the limiter. +- Host-level state cursors: generation-scoped entity sequences, + `changes`/`removals`/`snapshot` answers, and tombstones. The client + re-reads everything on `Resync` and on reconnect. +- Per-session epochs and a bounded first resume with `has_older`. +- `device.*` RPC methods. +- `#[non_exhaustive]` wire enums with an `Unknown` fallback. +- General feature gating. The client checks the protocol version and + the `uploadStreams` feature; nothing else is gated. +- Logical client sessions surviving 90 s across sockets, the permission + in-flight guard, and "answered on another device". Each socket is + independent; a second answer is a host error. +- A broadcast serialized once and filtered per socket. Each connection's + forwarder serializes independently. +- A foreground probe with a 3 s deadline that bypasses backoff. +- A lease claimed by the first ping. The lease runs from connection + creation. +- A default port used "unless taken". A taken port is a hard error. +- Integration setup edited from the client. Setup is refused remotely and + done on the host. +- The client showing the host's CUA status. +- Pinned roots re-keyed by host, and host-side tool details and tool + summaries display defaults. Those display defaults stay client-side. +- Streams opened by a control message. A stream opens with an `Open` + frame on its channel, and the RPC answer names the channel. +- A host rename UI. The store and manager can rename; nothing calls them. +- Loopback tests for epoch change, generation change, a permission race, + and revoke mid-connection. +- Compatibility tests that round-trip every wire type with unknown + fields; only the hello is tested that way. + +## Later: relay + +The relay lives inside the OpenSecret enclave and sees only ciphertext. +Design constraints to honor when it arrives, so nothing above changes: + +- The relay uses `wss://` to the enclave with the same Noise inside. The + enclave ingress is Cloudflare, nginx, socat, then axum, with a 300 s + idle timeout; the 10 s application ping already satisfies it, so the + ingress needs only an nginx upgrade block. +- One persistent host-to-relay connection carrying a mux with explicit + per-stream flow control. The relay buffers nothing and never drops a + frame on the host's behalf. No dial-back-per-client topology. +- Rendezvous by device public key. The host publishes reachability through + the user's encrypted KV store. +- A second dial function beside `connect_direct`, and a `relay` variant of + `HostConnection`. Connection probing with first-available activation and + hysteresis lands here. +- The handshake's mode byte doubles as the Noise prologue, so a relay + cannot swap the pairing and session handshakes. diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index be7b9ae44..53f6c869c 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -10,7 +10,7 @@ export CARGO_TERM_COLOR := "always" # incompatible toolchains in separate caches. export CARGO_BUILD_BUILD_DIR := `if [ -n "${CARGO_BUILD_BUILD_DIR:-}" ]; then printf "%s" "$CARGO_BUILD_BUILD_DIR"; elif [ -n "${CI:-}" ] || [ "${MAPLE_DISABLE_SHARED_CARGO_BUILD_DIR:-0}" = "1" ]; then printf target; elif command -v rustc >/dev/null 2>&1; then printf "%s/.cache/cargo-build/maple-agent/%s/rust-%s" "$HOME" "$(rustc -vV | awk '/^host:/{print $2}')" "$(rustc --version | awk '{print $2}')"; else printf target; fi` -headless := "--no-default-features --features acp,proxy" +headless := "--no-default-features --features acp,proxy,serve" # List the recipes. default: