From e69f8a8abab7d798c1c64009cd64991ccc5b8615 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:01:18 +0200 Subject: [PATCH 01/10] chore: add release workflow skill Co-Authored-By: OndeCode v1.0.1-acp --- .agents/skills/release/SKILL.md | 173 ++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .agents/skills/release/SKILL.md diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000..d68e736 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,173 @@ +--- +name: release +description: Cut a release of the onde-ed Rust workspace. Covers version bumping, the release branch/PR flow, tagging, and the two tag-triggered GitHub Actions (crates.io publish and Swift XCFramework). Use when asked to create, cut, or publish a release. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: Release + +## What This Skill Covers + +How to cut a release of the `onde-ed` Cargo workspace: bumping the version, +landing it through the branch/PR flow, tagging, and what the two tag-triggered +workflows do automatically afterwards. + +## The one-number rule + +The whole workspace releases on **one version**. Every crate declares +`version.workspace = true`, so a release changes exactly one source of truth: +the root `Cargo.toml`. + +```toml +[workspace.package] +version = "X.Y.Z" +``` + +Plus the internal path dependencies in the same file, which pin the version +explicitly: + +```toml +ed-agent = { version = "X.Y.Z", path = "crates/ed-agent" } +ed-mcp = { version = "X.Y.Z", path = "crates/ed-mcp" } +ed-acp = { version = "X.Y.Z", path = "crates/ed-acp" } +ed-acp-tui = { version = "X.Y.Z", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "X.Y.Z", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "X.Y.Z", path = "crates/ed-onde-account" } +``` + +Bump all of these together. The crate manifests under `crates/*/Cargo.toml` +need no edits. + +## Versioning + +Semver. Given the current `X.Y.Z`: +- **patch** (`Z+1`) — bug fixes, internal changes, no API additions. +- **minor** (`Y+1`, `Z=0`) — backwards-compatible API additions. +- **major** (`X+1`, `Y=Z=0`) — breaking changes. + +## Step-by-step + +The base branch is **`development`**; `main` is the promoted/stable branch. +Releases land on `development` first, get tagged, then `development` is promoted +to `main`. + +### 1. Branch off `development` + +```sh +git checkout development && git pull +git checkout -b release/X.Y.Z +``` + +### 2. Bump the version + +Edit the root `Cargo.toml` (`[workspace.package] version` and the internal +`ed-*` path-dependency versions). A quick way, run from the repo root: + +```sh +sed -i '' 's/OLD\.VER\.SION/X.Y.Z/g' Cargo.toml +``` + +### 3. Refresh the lockfile and verify + +```sh +cargo check --workspace # updates Cargo.lock, catches breakage +``` + +`cargo fmt --all -- --check`, `cargo test --locked --workspace`, and +`cargo clippy --locked --workspace --all-targets -- -D warnings` must all pass — +the release workflow re-runs them and fails the publish otherwise. + +### 4. Commit and push + +```sh +git add Cargo.toml Cargo.lock +git commit -m "Release X.Y.Z: bump the workspace" +git push +``` + +Commit subject convention: `Release X.Y.Z: bump the workspace`. + +### 5. Open the release PR → `development` + +```sh +gh pr create --base development --head release/X.Y.Z \ + --title "Release X.Y.Z" --body "..." +gh pr edit --add-assignee --add-reviewer +``` + +### 6. Merge, then tag the merge commit + +After approval, merge the PR, update local `development`, and tag **the merge +commit** (this is what every historical release tag points at): + +```sh +gh pr merge --merge +git checkout development && git pull +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +> The tag name carries a `v` prefix (`vX.Y.Z`); the workspace version does not +> (`X.Y.Z`). The workflows strip the `v` when comparing, so they must match on +> the numeric part. + +### 7. Promote `development` → `main` + +Open a second PR to bring `main` up to the tagged state: + +```sh +gh pr create --base main --head development --title "Merge development → main (vX.Y.Z)" +``` + +Merging it runs `ci.yml` on `main` as a final green check. + +## What the tag triggers + +Pushing a `vX.Y.Z` tag fires **two** workflows. Both start with a `preflight` +job that validates the tag matches the `ed-agent` version in `Cargo.toml`, then +runs fmt + test + clippy. If the version and tag disagree, the release fails +here — this is the safety net for step 6. + +### `release-sdk-rust.yml` → crates.io + +- Dry-runs the workspace publish with `cargo-workspaces` (plain + `cargo publish --dry-run` can't resolve the inter-crate path deps post-package). +- Publishes crates in dependency order to crates.io. +- `cargo workspaces plan --skip-published` makes re-runs safe: a run that dies + mid-publish only publishes the crates still missing from the registry. + +### `release-sdk-swift.yml` → XCFramework + `ed-swift` + +- Builds the `EdFramework.xcframework` for Apple targets (~2h20m build; + **not** cancel-in-progress — killing it leaves half-published state). +- Creates a GitHub Release with the xcframework zip + checksum as assets. +- Checks out `ondeinference/ed-swift`, rewrites `Package.swift` with the new + release URL + checksum, copies the generated `ed_agent_ffi.swift`, commits, + pushes to `ed-swift` `main`, and tags it `vX.Y.Z`. + +## Manual / recovery runs + +Both workflows have a `workflow_dispatch` with a `publish` boolean and a +`release_tag` input. Use this to re-publish an existing tag if a run failed +after tagging — e.g. the crates.io publish succeeded but the Swift build timed +out. Point it at the existing `vX.Y.Z` tag; the crates.io re-run skips anything +already on the registry. + +## Toolchain + +The Rust toolchain is pinned in `rust-toolchain.toml` (currently `1.94.0`). +Workflows read `channel` from it, so a toolchain bump is a normal source change, +not release-time config. + +## Checklist + +- [ ] On a `release/X.Y.Z` branch off `development`. +- [ ] Root `Cargo.toml`: `[workspace.package] version` + all `ed-*` path deps bumped. +- [ ] `cargo check --workspace` run (Cargo.lock updated). +- [ ] fmt / test / clippy all green locally. +- [ ] Commit `Release X.Y.Z: bump the workspace`. +- [ ] PR → `development`, reviewed, merged. +- [ ] `vX.Y.Z` tag on the merge commit, pushed. +- [ ] `development` → `main` PR opened/merged. +- [ ] Both tag workflows green (crates.io + Swift). From d108c006a0e302860f36b898d54678ce6e40c573 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:30:01 +0200 Subject: [PATCH 02/10] feat(ed-mcp): forward server elicitation/create to a client hook MCP servers can ask the end user for structured input mid-call via `elicitation/create`, but ed-mcp's client handler declared no `elicitation` capability and `rmcp` declined every server-initiated request, so embedders (e.g. sigit forwarding to its ACP editor) had no seam to receive one. Add an `Elicitation` trait as that seam. `ToolsetConfig::with_elicitation` installs a handler shared by every server in the session; when set, ed-mcp advertises the `elicitation` capability and forwards each `elicitation/create` to the handler, mapping its answer to the MCP accept/decline/cancel semantics. With no handler, requests are declined (the spec's safe default) and the capability is not advertised, so conforming servers won't ask. Plumbed through `Connection::connect_with` (the existing `connect` is now a no-hook shim) and `SharedServer`, so built-in and client-supplied servers share one hook. The elicitation request/result/action types are re-exported so embedders need not depend on rmcp directly. Tests: the in-process HTTP server gains a `collect_feedback` tool that elicits; new tests prove the request reaches the hook (with the asking server's name) and the Accept+content flows back, and that it's declined when no hook is set. Closes #33. Co-Authored-By: SplitFire v1.1.1-acp --- Cargo.lock | 1 + crates/ed-mcp/Cargo.toml | 4 +- crates/ed-mcp/src/lib.rs | 117 +++++++++++++++++++++++++++--- crates/ed-mcp/tests/http.rs | 140 ++++++++++++++++++++++++++++++++++-- 4 files changed, 248 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a14fba..95221a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6538,6 +6538,7 @@ dependencies = [ "tokio-util", "tower-service", "tracing", + "url", "uuid 1.27.0", ] diff --git a/crates/ed-mcp/Cargo.toml b/crates/ed-mcp/Cargo.toml index c184014..550b68a 100644 --- a/crates/ed-mcp/Cargo.toml +++ b/crates/ed-mcp/Cargo.toml @@ -35,7 +35,9 @@ agent = ["dep:ed-agent", "dep:async-trait"] test-server = [] [dev-dependencies] -rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] } +# `elicitation` adds the server peer's `create_elicitation`, so the in-process test server can +# ask the client for input and exercise the forwarding hook. +rmcp = { workspace = true, features = ["server", "transport-streamable-http-server", "elicitation"] } axum = "0.8" tokio = { workspace = true, features = ["net", "rt-multi-thread"] } # Run the feature-gated code's tests under a plain `cargo test`. diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs index dc246c2..1ca0396 100644 --- a/crates/ed-mcp/src/lib.rs +++ b/crates/ed-mcp/src/lib.rs @@ -30,13 +30,13 @@ use anyhow::{Context, Result, anyhow, bail}; use futures::StreamExt; use rmcp::handler::client::progress::ProgressDispatcher; use rmcp::model::{ - CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, Implementation, - ProgressNotificationParam, Request, ServerResult, Tool, + CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, ElicitationCapability, + Implementation, ProgressNotificationParam, Request, ServerResult, Tool, }; -use rmcp::service::{NotificationContext, PeerRequestOptions, RunningService}; +use rmcp::service::{NotificationContext, PeerRequestOptions, RequestContext, RunningService}; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess}; -use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use rmcp::{ClientHandler, ErrorData as McpError, RoleClient, ServiceExt}; use serde_json::{Map, Value, json}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -91,6 +91,35 @@ fn sanitize(part: &str) -> String { out.trim_matches('_').to_string() } +// The MCP elicitation types an [`Elicitation`] handler works with, re-exported so embedders +// need not depend on `rmcp` directly: the request a server sends, the client's answer, and the +// three actions it can take (accept with content, decline, cancel). +pub use rmcp::model::{ElicitRequestParams, ElicitResult, ElicitationAction}; + +/// A hook for server-initiated `elicitation/create`: an MCP server asking the end user for +/// structured input mid-call (see the [MCP elicitation spec]). +/// +/// `ed-mcp` is a client, not a UI, so it does not answer these itself. An embedder (a host that +/// wires `ed-mcp` into its own UX — e.g. forwarding to an ACP editor as a form) implements this +/// trait and hands it to [`ToolsetConfig::with_elicitation`]. The handler is shared by every +/// server in the session, built-in or client-supplied. +/// +/// The return maps straight to MCP semantics: [`ElicitationAction::Accept`] with `content`, +/// [`ElicitationAction::Decline`], or [`ElicitationAction::Cancel`]. Returning `Err` surfaces a +/// protocol error to the server. When no handler is set, `ed-mcp` declines every request, which +/// is the safe default the spec allows. +/// +/// [MCP elicitation spec]: https://modelcontextprotocol.io/specification/draft/client/elicitation +pub trait Elicitation: Send + Sync { + /// Answer one `elicitation/create` request. `server` is the name of the server that asked, + /// so the embedder can label its prompt. + fn elicit( + &self, + server: &str, + request: ElicitRequestParams, + ) -> futures::future::BoxFuture<'_, Result>; +} + /// Who is connecting: sent to every server in `initialize` as `clientInfo`. #[derive(Debug, Clone)] pub struct ClientInfo { @@ -228,6 +257,11 @@ impl ServerSpec { struct Handler { progress: ProgressDispatcher, info: ClientInfo, + /// The name of the server this handler is connected to, so an elicitation prompt can say + /// which server is asking. + server: String, + /// Where server-initiated `elicitation/create` requests go. `None` declines them all. + elicitation: Option>, } impl ClientHandler for Handler { @@ -239,9 +273,32 @@ impl ClientHandler for Handler { self.progress.handle_notification(params).await; } + async fn create_elicitation( + &self, + request: ElicitRequestParams, + _context: RequestContext, + ) -> Result { + // No embedder hook: decline, as the spec allows. We still advertised the capability so a + // conforming server may ask; declining is the honest answer to "we have no UI for this". + let Some(handler) = &self.elicitation else { + return Ok(ElicitResult::new(ElicitationAction::Decline)); + }; + handler + .elicit(&self.server, request) + .await + .map_err(|e| McpError::internal_error(format!("elicitation handler failed: {e}"), None)) + } + fn get_info(&self) -> rmcp::model::ClientConfig { + // Advertise the `elicitation` capability only when an embedder gave us somewhere to send + // the request. A conforming server will not attempt `elicitation/create` otherwise, which + // keeps us from advertising a form UI we cannot actually present. + let mut capabilities = ClientCapabilities::default(); + if self.elicitation.is_some() { + capabilities.elicitation = Some(ElicitationCapability::default()); + } rmcp::model::ClientConfig::new( - ClientCapabilities::default(), + capabilities, Implementation::new(self.info.name.clone(), self.info.version.clone()), ) } @@ -257,9 +314,22 @@ pub struct Connection { impl Connection { /// Connect to `spec`, run the handshake and list its tools, each within `timeout`. pub async fn connect(spec: &ServerSpec, info: &ClientInfo, timeout: Duration) -> Result { + Self::connect_with(spec, info, None, timeout).await + } + + /// Connect to `spec`, with an optional [`Elicitation`] hook for server-initiated + /// `elicitation/create` requests. [`Connection::connect`] is this with no hook. + pub async fn connect_with( + spec: &ServerSpec, + info: &ClientInfo, + elicitation: Option>, + timeout: Duration, + ) -> Result { let handler = Handler { progress: ProgressDispatcher::new(), info: info.clone(), + server: spec.name.clone(), + elicitation, }; let handshake = match &spec.transport { Transport::Stdio { command, args, env } => { @@ -380,7 +450,12 @@ impl SharedServer { self.name } - async fn get(&self, info: &ClientInfo, timeout: Duration) -> Option> { + async fn get( + &self, + info: &ClientInfo, + elicitation: Option>, + timeout: Duration, + ) -> Option> { let mut state = self.state.lock().await; match &*state { SharedState::Running(conn) => return Some(conn.clone()), @@ -392,7 +467,7 @@ impl SharedServer { tracing::info!("built-in MCP server `{}` is not available", self.name); return None; }; - match Connection::connect(&spec, info, timeout).await { + match Connection::connect_with(&spec, info, elicitation, timeout).await { Ok(conn) => { tracing::info!("built-in MCP server `{}` available", self.name); let conn = Arc::new(conn); @@ -424,6 +499,10 @@ pub struct ToolsetConfig { pub client: ClientInfo, pub connect_timeout: Duration, pub call_timeout: Duration, + /// Where server-initiated `elicitation/create` requests go. Set by + /// [`ToolsetConfig::with_elicitation`]; `None` (the default) declines them all and does not + /// advertise the capability. Shared by every server in the session. + pub elicitation: Option>, } impl ToolsetConfig { @@ -432,8 +511,16 @@ impl ToolsetConfig { client, connect_timeout: DEFAULT_CONNECT_TIMEOUT, call_timeout: DEFAULT_CALL_TIMEOUT, + elicitation: None, } } + + /// Forward servers' `elicitation/create` requests to `handler`. This also makes `ed-mcp` + /// advertise the `elicitation` client capability, so conforming servers will ask. + pub fn with_elicitation(mut self, handler: Arc) -> Self { + self.elicitation = Some(handler); + self + } } /// One MCP tool as the model sees it. @@ -524,8 +611,16 @@ impl McpToolset { builtins: &[&'static SharedServer], ) -> Self { let mut set = Self::empty(config); + let elicitation = set.config.elicitation.clone(); for spec in servers { - match Connection::connect(spec, &set.config.client, set.config.connect_timeout).await { + match Connection::connect_with( + spec, + &set.config.client, + elicitation.clone(), + set.config.connect_timeout, + ) + .await + { Ok(conn) => set.add(Arc::new(conn)), Err(e) => tracing::warn!("MCP server `{}` unavailable: {e:#}", spec.name), } @@ -535,7 +630,11 @@ impl McpToolset { continue; } if let Some(conn) = builtin - .get(&set.config.client, set.config.connect_timeout) + .get( + &set.config.client, + elicitation.clone(), + set.config.connect_timeout, + ) .await { set.add(conn); diff --git a/crates/ed-mcp/tests/http.rs b/crates/ed-mcp/tests/http.rs index 0528b74..aa2ecd0 100644 --- a/crates/ed-mcp/tests/http.rs +++ b/crates/ed-mcp/tests/http.rs @@ -9,7 +9,8 @@ use axum::middleware::{self, Next}; use axum::response::Response; use ed_agent::{ToolExecutor, ToolRisk}; use ed_mcp::{ - ClientInfo, Connection, McpExecutor, McpToolset, ServerSpec, ToolPolicy, ToolsetConfig, + ClientInfo, Connection, ElicitRequestParams, ElicitResult, Elicitation, ElicitationAction, + McpExecutor, McpToolset, ServerSpec, ToolPolicy, ToolsetConfig, }; use rmcp::RoleServer; use rmcp::ServerHandler; @@ -46,7 +47,9 @@ impl ServerHandler for Mailbox { .annotate(ToolAnnotations::new().read_only(true)), // Says nothing, like a server that predates annotations. Tool::new("list_messages", "Recent messages.", schema.clone()), - Tool::new("send_message", "Send mail.", schema), + Tool::new("send_message", "Send mail.", schema.clone()), + // Elicits structured input from the user mid-call. + Tool::new("collect_feedback", "Ask the user for feedback.", schema), ], ..Default::default() }) @@ -55,8 +58,43 @@ impl ServerHandler for Mailbox { async fn call_tool( &self, request: CallToolRequestParams, - _context: RequestContext, + context: RequestContext, ) -> Result { + // `collect_feedback` asks the client (via `elicitation/create`) for input and reports + // what came back, so a test can prove the request reached the client's hook and the + // answer flowed back. + if request.name == "collect_feedback" { + let params: ElicitRequestParams = serde_json::from_value(json!({ + "mode": "form", + "message": "How was it?", + "requestedSchema": { + "type": "object", + "properties": {"rating": {"type": "string"}}, + "required": ["rating"] + } + })) + .unwrap(); + let result = context + .peer + .create_elicitation(params) + .await + .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; + let text = match result.action { + ElicitationAction::Accept => { + let rating = result + .content + .as_ref() + .and_then(|c: &serde_json::Value| c.get("rating")) + .and_then(|v: &serde_json::Value| v.as_str()) + .unwrap_or(""); + format!("accepted: {rating}") + } + ElicitationAction::Decline => "declined".to_string(), + ElicitationAction::Cancel => "cancelled".to_string(), + _ => "unknown".to_string(), + }; + return Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()); + } Ok( CallToolResult::success(vec![ContentBlock::text(format!("ran {}", request.name))]) .into(), @@ -111,6 +149,7 @@ async fn connects_lists_and_calls_with_a_bearer_token() { assert_eq!( names, [ + "mcp__mail__collect_feedback", "mcp__mail__list_messages", "mcp__mail__send_message", "mcp__mail__whoami" @@ -140,7 +179,15 @@ async fn a_connection_lists_and_calls_tools_by_their_own_names() { let mut names: Vec<_> = conn.tools().iter().map(|t| t.name.to_string()).collect(); names.sort(); - assert_eq!(names, ["list_messages", "send_message", "whoami"]); + assert_eq!( + names, + [ + "collect_feedback", + "list_messages", + "send_message", + "whoami" + ] + ); let result = conn .call_tool("whoami", json!({}), Duration::from_secs(5)) @@ -231,6 +278,91 @@ async fn the_host_can_override_risk_and_drop_the_prefix() { assert!(exec.execute("mcp__mail__send_message", "{}").await.is_err()); } +/// Records the requests it is handed and answers each with a fixed action. +struct Recorder { + seen: Arc>>, + answer: ElicitResult, +} + +impl Elicitation for Recorder { + fn elicit( + &self, + server: &str, + request: ElicitRequestParams, + ) -> futures::future::BoxFuture<'_, anyhow::Result> { + self.seen + .lock() + .unwrap() + .push((server.to_string(), request)); + let answer = self.answer.clone(); + Box::pin(async move { Ok(answer) }) + } +} + +#[tokio::test] +async fn forwards_server_elicitation_to_the_hook() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let hook = Arc::new(Recorder { + seen: seen.clone(), + answer: ElicitResult::new(ElicitationAction::Accept) + .with_content(json!({"rating": "great"})), + }); + let config = ToolsetConfig::new(client()).with_elicitation(hook); + let set = McpToolset::connect_specs(config, &[spec], &[]).await; + + let out = set + .call( + "mcp__mail__collect_feedback", + json!({}), + &CancellationToken::new(), + &|_| {}, + ) + .await + .unwrap(); + + // The server's elicitation reached our hook, and the Accept + content flowed back to it. + assert!(!out.failed, "{}", out.text); + assert_eq!(out.text, "accepted: great"); + + // Scoped so the guard is dropped before the `await` below. + { + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1, "the hook was called exactly once"); + assert_eq!(seen[0].0, "mail", "the hook knows which server asked"); + match &seen[0].1 { + ElicitRequestParams::FormElicitationParams { message, .. } => { + assert_eq!(message, "How was it?"); + } + other => panic!("expected a form elicitation, got {other:?}"), + } + } + + set.shutdown().await; +} + +#[tokio::test] +async fn without_a_hook_server_elicitation_is_declined() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + + // No `with_elicitation`: the default declines, and the server sees a Decline. + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + let out = set + .call( + "mcp__mail__collect_feedback", + json!({}), + &CancellationToken::new(), + &|_| {}, + ) + .await + .unwrap(); + assert_eq!(out.text, "declined"); + set.shutdown().await; +} + #[tokio::test] async fn disconnecting_removes_the_tools() { let exec = executor(ToolPolicy::new()).await; From 40255cbd773ac6f20f107c120d209e4b65b9adf6 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:40:24 +0200 Subject: [PATCH 03/10] Add an auth seam to ed-acp and the ed-acp-tauri host crate Profile::auth lets a product bring its own sign-in. With a provider, initialize advertises its method whatever the client's terminal capability, the turn loop builds its client from the provider's stored session instead of the environment, and authenticate and logout delegate to it. authenticate already ran in a spawned task, so a provider can wait on a URL elicitation answer. Without a provider nothing changes. ed-acp-tauri is the generic host that splitfire_agent_host was: it serves a Profile in-process over Channel::duplex and talks to it as an ACP client. Roots come from the host per session instead of a fixed scratch directory, fs reads and writes are checked against the roots of the session that asked, and the client advertises elicitation.url and opens the page in the system browser. It adds set_config_option, authenticate and logout, and open_session returns the config options. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: siGit Code v1.6.7 --- .agents/AGENTS.md | 7 + Cargo.lock | 260 ++++++++++ Cargo.toml | 2 + crates/ed-acp-tauri/Cargo.toml | 24 + crates/ed-acp-tauri/src/client.rs | 797 ++++++++++++++++++++++++++++++ crates/ed-acp-tauri/src/lib.rs | 193 ++++++++ crates/ed-acp/src/agent.rs | 78 ++- crates/ed-acp/src/auth.rs | 51 ++ crates/ed-acp/src/lib.rs | 10 + crates/ed-acp/src/server.rs | 13 +- crates/ed-acp/tests/in_process.rs | 143 ++++++ 11 files changed, 1568 insertions(+), 10 deletions(-) create mode 100644 crates/ed-acp-tauri/Cargo.toml create mode 100644 crates/ed-acp-tauri/src/client.rs create mode 100644 crates/ed-acp-tauri/src/lib.rs create mode 100644 crates/ed-acp/src/auth.rs diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 63b7aa4..f555a9c 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -13,6 +13,7 @@ into focused crates; do not merge them. |---|---| | `ed-agent` | Core library: `Ed` struct, lifecycle, `send`/`stream`/`run`, tool approval, `EventSink`, `SessionStore` seams | | `ed-acp` | ACP v1 server: `Profile` trait, `serve`/`serve_stdio`, sessions, LLM streaming, tool routing, MCP | +| `ed-acp-tauri` | Hosts an `ed-acp` agent in a Tauri app: in-process `serve` over `Channel::duplex`, permission prompts, `fs/*` inside per-session roots, URL-elicitation sign-in via the system browser, session and config-option commands | | `ed-mcp` | MCP integration over `rmcp`: tool namespacing (`mcp____`, max 64 bytes), `SharedServer`, `McpExecutor`, timeouts, cancellation | | `ed-acp-tui` | ratatui TUI frontend for ACP agents over stdio | | `ed-acp-testkit` | Mock Onde Cloud endpoint + conformance test suite | @@ -45,6 +46,12 @@ into focused crates; do not merge them. 6. **Tauri and FFI are separate bindings.** `ed-agent-tauri` and `ed-agent-ffi` both wrap `ed-agent` but must not depend on each other. + `ed-acp-tauri` is the Tauri host for `ed-acp` agents; it does not depend on + `ed-agent-tauri`. + +7. **A product's own sign-in goes through `Profile::auth`.** `ed-acp` knows + terminal auth and the `_*` env file. A product with a browser or + session sign-in supplies an `AuthProvider`; do not put its flow in `ed-acp`. --- diff --git a/Cargo.lock b/Cargo.lock index 3a14fba..c0fe3f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,18 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -347,6 +359,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -394,6 +420,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "async-signal" version = "0.2.14" @@ -1979,6 +2016,22 @@ dependencies = [ "uuid 1.27.0", ] +[[package]] +name = "ed-acp-tauri" +version = "1.2.2" +dependencies = [ + "agent-client-protocol", + "ed-acp", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin-opener", + "tokio", + "tsync", + "uuid 1.27.0", +] + [[package]] name = "ed-acp-testkit" version = "1.2.2" @@ -2145,6 +2198,12 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "endian-type" version = "0.2.0" @@ -2170,6 +2229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", + "serde", ] [[package]] @@ -3760,6 +3820,25 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -5401,6 +5480,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openai-harmony" version = "0.0.8" @@ -5446,6 +5536,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "packedvec" version = "1.2.5" @@ -8139,6 +8239,44 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows 0.62.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -8913,6 +9051,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ug" version = "0.5.0" @@ -10366,6 +10515,76 @@ dependencies = [ "synstructure 0.14.0", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid 1.27.0", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -10493,3 +10712,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index ef4c663..e3ee1ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/ed-agent-tauri", "crates/ed-mcp", "crates/ed-acp", + "crates/ed-acp-tauri", "crates/ed-acp-tui", "crates/ed-acp-testkit", "crates/ed-onde-account", @@ -61,6 +62,7 @@ base64 = "0.23" ed-agent = { version = "1.2.2", path = "crates/ed-agent" } ed-mcp = { version = "1.2.2", path = "crates/ed-mcp" } ed-acp = { version = "1.2.2", path = "crates/ed-acp" } +ed-acp-tauri = { version = "1.2.2", path = "crates/ed-acp-tauri" } ed-acp-tui = { version = "1.2.2", path = "crates/ed-acp-tui" } ed-acp-testkit = { version = "1.2.2", path = "crates/ed-acp-testkit" } ed-onde-account = { version = "1.2.2", path = "crates/ed-onde-account" } diff --git a/crates/ed-acp-tauri/Cargo.toml b/crates/ed-acp-tauri/Cargo.toml new file mode 100644 index 0000000..3ea7c2d --- /dev/null +++ b/crates/ed-acp-tauri/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ed-acp-tauri" +description = "Hosts an ed-acp agent inside a Tauri app: in-process ACP serve, permissions, sign-in, sessions and the webview events." +keywords = ["tauri", "acp", "agent", "onde", "mcp"] +categories = ["development-tools", "gui"] +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +ed-acp.workspace = true +agent-client-protocol.workspace = true +tauri.workspace = true +tauri-plugin-opener = "2" +log.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["sync", "rt"] } +tsync = "2" +uuid.workspace = true diff --git a/crates/ed-acp-tauri/src/client.rs b/crates/ed-acp-tauri/src/client.rs new file mode 100644 index 0000000..85012c9 --- /dev/null +++ b/crates/ed-acp-tauri/src/client.rs @@ -0,0 +1,797 @@ +//! The ACP client side, and the agent it serves in-process. +//! +//! [`AgentState::connection`] starts both halves on first use: +//! +//! - `ed_acp::serve(profile)` on one end of a [`Channel::duplex`], exactly what the product's +//! agent runs over stdio; +//! - an ACP client on the other end, which forwards `session/update` notifications and +//! `session/request_permission` requests to the webview, serves `fs/read_text_file` and +//! `fs/write_text_file` inside the session's roots, and opens the page of a URL-mode +//! elicitation in the system browser. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use agent_client_protocol::schema::v1::{ + AuthMethodId, AuthenticateRequest, CancelNotification, ClientCapabilities, + CompleteElicitationNotification, ContentBlock, CreateElicitationRequest, + CreateElicitationResponse, ElicitationAcceptAction, ElicitationAction, ElicitationCapabilities, + ElicitationMode, ElicitationUrlCapabilities, ErrorCode, FileSystemCapabilities, Implementation, + InitializeRequest, ListSessionsRequest, LoadSessionRequest, LogoutRequest, NewSessionRequest, + PromptRequest, ReadTextFileRequest, ReadTextFileResponse, RequestPermissionOutcome, + RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, + SessionConfigOption, SessionConfigOptionValue, SessionId, SessionNotification, + SetSessionConfigOptionRequest, TextContent, WriteTextFileRequest, WriteTextFileResponse, +}; +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::{Agent, Channel, Client, ConnectionTo}; +use ed_acp::llm::LlmClient; +use ed_acp::{cli, LlmConfig, ServeOptions}; +use log::{error, info, warn}; +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_opener::OpenerExt; +use tokio::sync::oneshot; + +use crate::{ + AgentSessionOpened, AgentSessionSummary, AgentStatus, AgentTurnEndedPayload, HostConfig, Roots, + AUTH_REQUIRED, +}; + +type Pending = Arc>>>; +/// The roots each open session may use, by session id. +type SessionRoots = Arc>>; + +/// Managed Tauri state: the live connection and the permission requests waiting on the user. +pub struct AgentState { + config: HostConfig, + connection: tokio::sync::Mutex>, + permissions: Pending, + roots: SessionRoots, +} + +struct Live { + conn: ConnectionTo, + /// The ids of the auth methods the agent advertised in `initialize`. + auth_methods: Vec, + /// Dropped (or fired) to hang up, which stops the agent too. + _hang_up: oneshot::Sender<()>, + /// Set when the client side has ended, so the next call reconnects. + ended: Arc, +} + +/// The `cwd` to ask `session/list` for, or `None` for every stored session. +/// +/// On a desktop the agent's data directory can be shared with the command-line agent, so the +/// app asks only for the conversations in the folder it opened. On iOS that directory is +/// inside the app's container, which nothing else writes to and whose path changes with every +/// app update. Sessions stored under the old path would never match, and the user's earlier +/// conversations would drop out of the list. Loading one rebinds it to the current path. +fn list_filter(cwd: &Path, private_data_dir: bool) -> Option { + (!private_data_dir).then(|| cwd.to_path_buf()) +} + +fn list_request(roots: &Roots) -> ListSessionsRequest { + ListSessionsRequest::new().cwd(list_filter(&roots.cwd, cfg!(target_os = "ios"))) +} + +/// What the agent will use, without starting it: the profile's own session when it has an +/// auth provider, else the environment and the stored `env` file. +pub fn status(host: &HostConfig) -> AgentStatus { + let profile = (host.profile)(); + let config = match profile.auth() { + Some(auth) => auth.llm_config(), + None => Some(LlmConfig::from_env(&profile.info().env)), + }; + match config { + Some(config) => AgentStatus { + available: true, + has_key: config.api_key.is_some(), + model: config.model, + }, + None => AgentStatus { + available: true, + has_key: false, + model: profile.info().env.default_model.to_string(), + }, + } +} + +/// What ` --setup` does for Onde Inference, minus the terminal: check the key with Onde +/// Cloud, then write it to the agent's `env` file (mode 0600). With a `model` it is written as +/// `_MODEL` beside the key and used for the check, so the agent doesn't fall back to a +/// default tier the key isn't allowed to call. In the App Store sandbox that file lives in +/// the app's container, so a key stored by the command-line agent isn't shared. +pub async fn set_api_key(host: &HostConfig, key: &str, model: Option<&str>) -> Result<(), String> { + let info = host.info(); + let key = key.trim(); + if !cli::valid_onde_key(key) { + return Err("An Onde key looks like \"app-id:app-secret\".".to_string()); + } + let provider_var = info.env.var("PROVIDER"); + let mut vars = vec![ + (provider_var, "onde".to_string()), + ("ONDE_API_KEY".to_string(), key.to_string()), + ]; + if let Some(model) = model { + vars.push((info.env.var("MODEL"), model.to_string())); + } + let config = LlmConfig::from_lookup(&info.env, |name| { + vars.iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + .or_else(|| std::env::var(name).ok()) + }); + LlmClient::new(config) + .check_auth() + .await + .map_err(|e| format!("Onde Cloud rejected the key: {e:#}"))?; + + let dir = info + .env + .config_dir() + .ok_or("No config directory available.")?; + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = dir.join("env"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + std::fs::write(&path, cli::with_vars(&existing, &vars)) + .map_err(|e| format!("{}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("{}: {e}", path.display()))?; + } + info!("Stored the agent's Onde key in {}", path.display()); + Ok(()) +} + +/// Remove the agent's Onde key, and the model that came with it, from its env file. Nothing to +/// do when there is no file or no key in it. +pub fn clear_api_key(host: &HostConfig) -> Result<(), String> { + let info = host.info(); + let Some(dir) = info.env.config_dir() else { + return Ok(()); + }; + let path = dir.join("env"); + let existing = match std::fs::read_to_string(&path) { + Ok(existing) => existing, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + let (kept, removed) = without_api_key(&existing, &info.env.var("MODEL")); + if removed { + std::fs::write(&path, kept).map_err(|e| format!("{}: {e}", path.display()))?; + info!("Removed the agent's Onde key from {}", path.display()); + } + Ok(()) +} + +/// `env` file content without any `ONDE_API_KEY` or `model_var` line, and whether a key was +/// there. +fn without_api_key(existing: &str, model_var: &str) -> (String, bool) { + let model_prefix = format!("{model_var}="); + let mut removed = false; + let kept: Vec<&str> = existing + .lines() + .filter(|line| { + let line = line.trim(); + let line = line.strip_prefix("export ").unwrap_or(line); + let is_key = line.starts_with("ONDE_API_KEY="); + removed |= is_key; + !is_key && !line.starts_with(&model_prefix) + }) + .collect(); + let mut out = kept.join("\n"); + if !out.is_empty() { + out.push('\n'); + } + (out, removed) +} + +impl AgentState { + pub fn new(config: HostConfig) -> Self { + Self { + config, + connection: tokio::sync::Mutex::new(None), + permissions: Pending::default(), + roots: SessionRoots::default(), + } + } + + /// The live connection, starting the agent and the client on first use or after the + /// previous connection ended. + async fn connection( + &self, + app: &AppHandle, + ) -> Result<(ConnectionTo, Vec), String> { + let mut slot = self.connection.lock().await; + if let Some(live) = slot.as_ref() { + if !live.ended.load(Ordering::SeqCst) { + return Ok((live.conn.clone(), live.auth_methods.clone())); + } + } + let live = self.connect(app).await?; + let out = (live.conn.clone(), live.auth_methods.clone()); + *slot = Some(live); + Ok(out) + } + + async fn connect(&self, app: &AppHandle) -> Result { + let (agent_end, client_end) = Channel::duplex(); + + let opts = ServeOptions { + yolo: false, + surface: self.config.surface, + }; + let profile = (self.config.profile)(); + let agent_name = profile.info().display_name; + tauri::async_runtime::spawn(async move { + if let Err(e) = ed_acp::serve(profile, opts, agent_end).await { + error!("{agent_name} agent stopped: {e}"); + } + }); + + let (ready_tx, ready_rx) = + oneshot::channel::, Vec), String>>(); + let (hang_up, hung_up) = oneshot::channel::<()>(); + let ended = Arc::new(AtomicBool::new(false)); + + let client = Client + .builder() + .name(self.config.client_name) + .on_receive_notification( + { + let app = app.clone(); + let event = self.config.events.session_update; + async move |n: SessionNotification, _cx| { + if let Err(e) = app.emit(event, &n) { + error!("Failed to emit agent session update: {e:?}"); + } + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_notification( + async move |n: CompleteElicitationNotification, _cx| { + info!("Sign-in page completed ({})", n.elicitation_id); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + { + let app = app.clone(); + async move |req: CreateElicitationRequest, responder, _cx| { + responder.respond(CreateElicitationResponse::new(open_url(&app, &req))) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let app = app.clone(); + let pending = self.permissions.clone(); + let event = self.config.events.permission_requested; + async move |req: RequestPermissionRequest, responder, cx| { + // Wait for the user off the dispatch loop so updates keep flowing. + let request_id = uuid::Uuid::new_v4().to_string(); + let (answer_tx, answer_rx) = oneshot::channel(); + pending + .lock() + .unwrap() + .insert(request_id.clone(), answer_tx); + let payload = json!({ "requestId": request_id, "request": req }); + if let Err(e) = app.emit(event, &payload) { + error!("Failed to emit agent permission request: {e:?}"); + pending.lock().unwrap().remove(&request_id); + } + cx.spawn(async move { + let outcome = answer_rx + .await + .unwrap_or(RequestPermissionOutcome::Cancelled); + responder.respond(RequestPermissionResponse::new(outcome)) + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let roots = self.roots.clone(); + async move |req: ReadTextFileRequest, responder, _cx| { + let roots = roots.clone(); + let result = + tokio::task::spawn_blocking(move || read_text_file(&roots, &req)).await; + match result { + Ok(Ok(r)) => responder.respond(r), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + } + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let roots = self.roots.clone(); + async move |req: WriteTextFileRequest, responder, _cx| { + let roots = roots.clone(); + let result = + tokio::task::spawn_blocking(move || write_text_file(&roots, &req)) + .await; + match result { + Ok(Ok(r)) => responder.respond(r), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + } + } + }, + agent_client_protocol::on_receive_request!(), + ); + + let ended_flag = ended.clone(); + let pending = self.permissions.clone(); + let client_name = self.config.client_name; + let client_version = self.config.client_version; + tauri::async_runtime::spawn(async move { + let result = client + .connect_with(client_end, |conn: ConnectionTo| async move { + let init = conn + .send_request( + InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities( + ClientCapabilities::new() + .fs(FileSystemCapabilities::new() + .read_text_file(true) + .write_text_file(true)) + .elicitation( + ElicitationCapabilities::new() + .url(ElicitationUrlCapabilities::new()), + ), + ) + .client_info(Implementation::new(client_name, client_version)), + ) + .block_task() + .await; + match init { + Ok(init) => { + let methods = init + .auth_methods + .iter() + .map(|m| m.id().to_string()) + .collect(); + let _ = ready_tx.send(Ok((conn, methods))); + // Stay connected until the state is dropped or replaced. + let _ = hung_up.await; + Ok(()) + } + Err(e) => { + let _ = ready_tx.send(Err(format!("Agent failed to start: {e}"))); + Err(e) + } + } + }) + .await; + if let Err(e) = result { + warn!("Agent connection ended: {e}"); + } + ended_flag.store(true, Ordering::SeqCst); + // Nobody can answer these any more. + pending.lock().unwrap().clear(); + }); + + let (conn, auth_methods) = ready_rx + .await + .map_err(|_| "Agent failed to start.".to_string())??; + info!("Agent connected in-process"); + Ok(Live { + conn, + auth_methods, + _hang_up: hang_up, + ended, + }) + } + + fn roots_for(&self, app: &AppHandle, cwd: Option<&Path>) -> Result { + (self.config.roots)(app, cwd) + } + + /// The agent's conversations in `cwd` (the host's default roots when `None`), newest + /// first. + pub async fn list_sessions( + &self, + app: &AppHandle, + cwd: Option, + ) -> Result, String> { + let roots = self.roots_for(app, cwd.as_deref())?; + let (conn, _) = self.connection(app).await?; + let listed = conn + .send_request(list_request(&roots)) + .block_task() + .await + .map_err(describe)?; + Ok(listed + .sessions + .into_iter() + .map(|session| AgentSessionSummary { + session_id: session.session_id.to_string(), + title: session.title, + updated_at: session.updated_at, + }) + .collect()) + } + + /// Open a conversation in `cwd`: the one named by `session_id`, else (unless `fresh`) the + /// latest in that folder, else a new one. + pub async fn open_session( + &self, + app: &AppHandle, + cwd: Option, + fresh: bool, + access_token: Option, + session_id: Option, + ) -> Result { + let roots = self.roots_for(app, cwd.as_deref())?; + let (conn, _) = self.connection(app).await?; + let mcp_servers = (self.config.mcp_servers)(app, access_token); + // The one the user picked, else the latest. + let picked = session_id.is_some(); + let target = match session_id { + Some(id) => Some(SessionId::new(id)), + None if !fresh => conn + .send_request(list_request(&roots)) + .block_task() + .await + .map_err(describe)? + .sessions + .into_iter() + // Newest first. + .next() + .map(|latest| latest.session_id), + None => None, + }; + if let Some(id) = target { + // The agent may read files while it replays, so the roots come first. + self.roots + .lock() + .unwrap() + .insert(id.to_string(), roots.clone()); + let load = LoadSessionRequest::new(id.clone(), roots.cwd.clone()) + .additional_directories(roots.extra.clone()) + .mcp_servers(mcp_servers.clone()); + match conn.send_request(load).block_task().await { + Ok(loaded) => { + return Ok(AgentSessionOpened { + session_id: id.to_string(), + restored: true, + config_options: option_values(loaded.config_options), + }); + } + Err(e) if e.code == ErrorCode::AuthRequired => { + self.roots.lock().unwrap().remove(&id.to_string()); + return Err(AUTH_REQUIRED.to_string()); + } + Err(e) => { + self.roots.lock().unwrap().remove(&id.to_string()); + // One the user picked and can't have is an error to show. + if picked { + return Err(describe(e)); + } + // The latest no longer loading shouldn't block a new one. + warn!("Could not load agent session {id}: {e}"); + } + } + } + let new = NewSessionRequest::new(roots.cwd.clone()) + .additional_directories(roots.extra.clone()) + .mcp_servers(mcp_servers); + let session = conn + .send_request(new) + .block_task() + .await + .map_err(describe)?; + self.roots + .lock() + .unwrap() + .insert(session.session_id.to_string(), roots); + Ok(AgentSessionOpened { + session_id: session.session_id.to_string(), + restored: false, + config_options: option_values(session.config_options), + }) + } + + pub async fn prompt( + &self, + app: &AppHandle, + session_id: String, + text: String, + ) -> Result<(), String> { + let (conn, _) = self.connection(app).await?; + let request = PromptRequest::new( + SessionId::new(session_id.clone()), + vec![ContentBlock::Text(TextContent::new(text))], + ); + let app = app.clone(); + let event = self.config.events.turn_ended; + // Returned before the turn ends: a long turn would otherwise hold the invoke + // callback open for minutes. + tauri::async_runtime::spawn(async move { + let result = conn.send_request(request).block_task().await; + let payload = match result { + Ok(r) => AgentTurnEndedPayload { + session_id, + stop_reason: serde_json::to_value(r.stop_reason) + .ok() + .and_then(|v| v.as_str().map(String::from)), + error: None, + }, + Err(e) => AgentTurnEndedPayload { + session_id, + stop_reason: None, + error: Some(describe(e)), + }, + }; + if let Err(e) = app.emit(event, &payload) { + error!("Failed to emit agent turn end: {e:?}"); + } + }); + Ok(()) + } + + pub async fn cancel(&self, session_id: String) -> Result<(), String> { + // ACP: a client cancelling a turn answers its open permission requests with + // `cancelled`. + for (_, answer) in self.permissions.lock().unwrap().drain() { + let _ = answer.send(RequestPermissionOutcome::Cancelled); + } + let slot = self.connection.lock().await; + let Some(live) = slot.as_ref() else { + return Ok(()); + }; + live.conn + .send_notification(CancelNotification::new(SessionId::new(session_id))) + .map_err(|e| e.to_string()) + } + + pub fn respond_permission( + &self, + request_id: &str, + option_id: Option, + ) -> Result<(), String> { + let answer = self + .permissions + .lock() + .unwrap() + .remove(request_id) + .ok_or("That request is no longer waiting for an answer.")?; + let outcome = match option_id { + Some(id) => RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)), + None => RequestPermissionOutcome::Cancelled, + }; + let _ = answer.send(outcome); + Ok(()) + } + + /// Change a config option (the model, auto-approve). `value` is a select value id as a + /// string, or a boolean. Returns the session's options as they are afterwards. + pub async fn set_config_option( + &self, + app: &AppHandle, + session_id: String, + config_id: String, + value: serde_json::Value, + ) -> Result, String> { + let value = match value { + serde_json::Value::Bool(value) => SessionConfigOptionValue::from(value), + serde_json::Value::String(value) => SessionConfigOptionValue::from(value.as_str()), + other => return Err(format!("Not a config option value: {other}")), + }; + let (conn, _) = self.connection(app).await?; + let response = conn + .send_request(SetSessionConfigOptionRequest::new( + SessionId::new(session_id), + config_id, + value, + )) + .block_task() + .await + .map_err(describe)?; + Ok(option_values(Some(response.config_options))) + } + + /// Sign in with one of the agent's auth methods: `method_id`, else the first it + /// advertised. For a product with its own sign-in this opens the browser through the + /// client's URL elicitation and returns once the page is finished. + pub async fn authenticate( + &self, + app: &AppHandle, + method_id: Option, + ) -> Result<(), String> { + let (conn, methods) = self.connection(app).await?; + let method = method_id + .or_else(|| methods.first().cloned()) + .ok_or("The agent offers no way to sign in.")?; + conn.send_request(AuthenticateRequest::new(AuthMethodId::new(method))) + .block_task() + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Sign out: the agent forgets its stored session. + pub async fn logout(&self, app: &AppHandle) -> Result<(), String> { + let (conn, _) = self.connection(app).await?; + conn.send_request(LogoutRequest::new()) + .block_task() + .await + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +/// Answer a URL-mode elicitation by opening its page in the system browser. Only `http` and +/// `https` are opened; the user "declines" anything else, or a browser that won't launch. +fn open_url(app: &AppHandle, req: &CreateElicitationRequest) -> ElicitationAction { + let ElicitationMode::Url(mode) = &req.mode else { + // Form mode was never advertised. + return ElicitationAction::Decline; + }; + if !(mode.url.starts_with("https://") || mode.url.starts_with("http://")) { + warn!("Refusing to open a non-web sign-in URL"); + return ElicitationAction::Decline; + } + match app.opener().open_url(&mode.url, None::<&str>) { + Ok(()) => ElicitationAction::Accept(ElicitationAcceptAction::new()), + Err(e) => { + warn!("Could not open the sign-in page: {e}"); + ElicitationAction::Decline + } + } +} + +fn option_values(options: Option>) -> Vec { + options + .unwrap_or_default() + .into_iter() + .filter_map(|option| serde_json::to_value(option).ok()) + .collect() +} + +/// [`AUTH_REQUIRED`] for a missing key or session, so the page can ask for sign-in; the +/// agent's message otherwise. +fn describe(e: agent_client_protocol::Error) -> String { + if e.code == ErrorCode::AuthRequired { + AUTH_REQUIRED.to_string() + } else { + e.to_string() + } +} + +fn outside(path: &Path) -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data(format!( + "{} is outside the session's workspace", + path.display() + )) +} + +fn roots_of( + roots: &SessionRoots, + session: &SessionId, + path: &Path, +) -> agent_client_protocol::Result { + roots + .lock() + .unwrap() + .get(&session.to_string()) + .cloned() + .filter(|roots| roots.contains(path)) + .ok_or_else(|| outside(path)) +} + +/// `fs/read_text_file`, honouring the optional 1-based `line` and `limit`. +fn read_text_file( + roots: &SessionRoots, + req: &ReadTextFileRequest, +) -> agent_client_protocol::Result { + roots_of(roots, &req.session_id, &req.path)?; + let content = std::fs::read_to_string(&req.path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + agent_client_protocol::Error::resource_not_found(Some(req.path.display().to_string())) + } else { + agent_client_protocol::Error::internal_error().data(e.to_string()) + } + })?; + let skip = req.line.map_or(0, |l| l.saturating_sub(1) as usize); + let take = req.limit.map_or(usize::MAX, |l| l as usize); + let content = content + .lines() + .skip(skip) + .take(take) + .collect::>() + .join("\n"); + Ok(ReadTextFileResponse::new(content)) +} + +/// `fs/write_text_file`: the client creates the file and its parent directories when they +/// don't exist. +fn write_text_file( + roots: &SessionRoots, + req: &WriteTextFileRequest, +) -> agent_client_protocol::Result { + roots_of(roots, &req.session_id, &req.path)?; + let write = || { + if let Some(parent) = req.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&req.path, &req.content) + }; + write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?; + Ok(WriteTextFileResponse::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sessions_are_listed_by_directory_unless_the_data_dir_is_private() { + let cwd = Path::new("/data/agent"); + assert_eq!(list_filter(cwd, false), Some(cwd.to_path_buf())); + // iOS: the container path changes with every update, so no filter. + assert_eq!(list_filter(cwd, true), None); + } + + #[test] + fn removing_the_key_keeps_every_other_line() { + let (kept, removed) = without_api_key( + "AGENT_PROVIDER=onde\nONDE_API_KEY=app:secret\nAGENT_MODEL=x\nAGENT_SURFACE=app\n", + "AGENT_MODEL", + ); + assert!(removed); + assert_eq!(kept, "AGENT_PROVIDER=onde\nAGENT_SURFACE=app\n"); + } + + #[test] + fn an_exported_key_goes_too_and_a_file_without_one_is_untouched() { + assert_eq!( + without_api_key("export ONDE_API_KEY=a:b\n", "AGENT_MODEL"), + (String::new(), true) + ); + assert_eq!( + without_api_key("AGENT_PROVIDER=onde\n", "AGENT_MODEL"), + ("AGENT_PROVIDER=onde\n".to_string(), false) + ); + assert_eq!(without_api_key("", "AGENT_MODEL"), (String::new(), false)); + } + + #[test] + fn a_similar_variable_is_not_the_key() { + let (kept, removed) = + without_api_key("ONDE_API_KEY_BACKUP=x\nAGENT_MODEL_X=y\n", "AGENT_MODEL"); + assert!(!removed); + assert_eq!(kept, "ONDE_API_KEY_BACKUP=x\nAGENT_MODEL_X=y\n"); + } + + #[test] + fn a_session_reaches_only_its_own_roots() { + let roots = SessionRoots::default(); + roots.lock().unwrap().insert( + "a".into(), + Roots { + cwd: PathBuf::from("/work/a"), + extra: Vec::new(), + }, + ); + let a = SessionId::new("a"); + let b = SessionId::new("b"); + assert!(roots_of(&roots, &a, Path::new("/work/a/src/main.rs")).is_ok()); + assert!(roots_of(&roots, &a, Path::new("/work/b/src/main.rs")).is_err()); + // An id nobody opened has no roots at all. + assert!(roots_of(&roots, &b, Path::new("/work/a/src/main.rs")).is_err()); + } +} diff --git a/crates/ed-acp-tauri/src/lib.rs b/crates/ed-acp-tauri/src/lib.rs new file mode 100644 index 0000000..467ed7a --- /dev/null +++ b/crates/ed-acp-tauri/src/lib.rs @@ -0,0 +1,193 @@ +//! An [`ed_acp`] agent, hosted inside a Tauri app. +//! +//! A sandboxed App Store build can't launch an ACP agent as a subprocess the way an editor +//! does, so the app runs it in-process: [`AgentState`] serves the agent's [`Profile`] with +//! `ed_acp::serve` over an in-memory channel and talks to it as a regular ACP client. Nothing +//! about the protocol changes; only the transport does. +//! +//! The frontend sees ACP as Tauri commands and events. Session updates are forwarded +//! untouched, so the page renders the ACP schema directly. This crate holds everything that +//! doesn't depend on the app; each app passes a [`HostConfig`] and wraps [`AgentState`]'s +//! methods in its own `#[tauri::command]`s. +//! +//! The client advertises `fs.readTextFile`, `fs.writeTextFile` and `elicitation.url`. Reads +//! and writes are served inside the session's [`Roots`]. A URL elicitation (a product's own +//! sign-in page) is opened in the system browser through `tauri-plugin-opener`, which the app +//! has to register. The client advertises no `terminal`, so the agent runs commands itself, +//! after asking for permission. + +mod client; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use ed_acp::agent_client_protocol::schema::v1::McpServer; +use ed_acp::{normalize, AgentInfo, Profile}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; +use tsync::tsync; + +pub use client::{clear_api_key, set_api_key, status, AgentState}; + +/// Returned by commands when the agent isn't signed in or has no API key. The page offers +/// sign-in when it sees this. +pub const AUTH_REQUIRED: &str = "auth_required"; + +/// Names of the Tauri events the host emits to the webview. +#[derive(Clone, Copy)] +pub struct Events { + /// ACP `session/update` notifications, forwarded untouched. + pub session_update: &'static str, + /// `{ requestId, request }` for a permission prompt. + pub permission_requested: &'static str, + /// An [`AgentTurnEndedPayload`]. + pub turn_ended: &'static str, +} + +/// The directories a session may use: its working directory and extra roots. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Roots { + pub cwd: PathBuf, + pub extra: Vec, +} + +impl Roots { + /// The app's own scratch directory (`/agent`), created if missing, plus `extra`. + /// What a host that has no project folder of its own wants as `cwd`. + pub fn app_scratch(app: &AppHandle, extra: Vec) -> Result { + let cwd = app + .path() + .app_data_dir() + .map_err(|e| format!("No app data directory: {e}"))? + .join("agent"); + std::fs::create_dir_all(&cwd).map_err(|e| format!("{}: {e}", cwd.display()))?; + Ok(Self { cwd, extra }) + } + + /// A project folder as the working directory. It must be absolute and an existing + /// directory: it becomes the boundary the agent's file tools stay inside. + pub fn project(cwd: &Path) -> Result { + if !cwd.is_absolute() { + return Err(format!("{} is not an absolute path.", cwd.display())); + } + if !cwd.is_dir() { + return Err(format!("{} is not a directory.", cwd.display())); + } + Ok(Self { + cwd: cwd.to_path_buf(), + extra: Vec::new(), + }) + } + + /// Whether `path` is absolute and inside one of the roots, after removing `.` and `..`, + /// so the agent can't walk out of them. + pub fn contains(&self, path: &Path) -> bool { + if !path.is_absolute() { + return false; + } + let path = normalize(path); + std::iter::once(&self.cwd) + .chain(&self.extra) + .any(|root| path.starts_with(normalize(root))) + } +} + +/// Everything about the app that the host can't know. +#[derive(Clone)] +pub struct HostConfig { + /// Reported to the agent as `clientInfo.name`. + pub client_name: &'static str, + /// `clientInfo.version`. + pub client_version: &'static str, + /// `_SURFACE` for the agent's prompt: `app`, neither an editor nor its TUI. + pub surface: &'static str, + /// The agent's profile, without what the platform can't run. + pub profile: fn() -> Arc, + /// The roots for a session. The argument is the folder the frontend asked for, if any; + /// `Roots::app_scratch` is the answer for an app without project folders. + pub roots: fn(&AppHandle, Option<&Path>) -> Result, + /// The MCP servers for a new session, given the signed-in user's access token when the app + /// has one its agent should present. A profile's own built-in servers need none. + pub mcp_servers: fn(&AppHandle, Option) -> Vec, + pub events: Events, +} + +impl HostConfig { + /// The profile's identity: where its env file lives and which variables it reads. + pub fn info(&self) -> AgentInfo { + (self.profile)().info() + } +} + +/// Response returned by `agent_status`. +#[tsync] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentStatus { + /// `false` on platforms the agent isn't built for. + pub available: bool, + /// Whether the agent has credentials: a signed-in session or an API key. + pub has_key: bool, + /// The model the agent will use. + pub model: String, +} + +/// One earlier conversation, as `agent_list_sessions` returns it. +#[tsync] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSessionSummary { + pub session_id: String, + /// Set by the agent once the conversation has a first exchange. + pub title: Option, + /// ISO 8601, when the conversation last changed. + pub updated_at: Option, +} + +/// Response returned by `agent_open_session`. +#[tsync] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSessionOpened { + pub session_id: String, + /// `true` when an earlier conversation was loaded. Its history arrives as + /// `agent_session_update` events before this response. + pub restored: bool, + /// The session's ACP `SessionConfigOption`s (model picker, auto-approve), as the agent + /// sent them. `Value` is any JSON. + pub config_options: Vec, +} + +/// Payload emitted with `agent_turn_ended`. +#[tsync] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentTurnEndedPayload { + pub session_id: String, + /// ACP stop reason (`end_turn`, `cancelled`, `max_tokens`, …), or `None` + /// on error. + pub stop_reason: Option, + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_keep_the_agent_inside() { + let roots = Roots { + cwd: PathBuf::from("/data/agent"), + extra: vec![PathBuf::from("/data/audio")], + }; + assert!(roots.contains(Path::new("/data/agent/notes.md"))); + assert!(roots.contains(Path::new("/data/audio/song/bass.wav"))); + assert!(!roots.contains(Path::new("/data/agent/../secrets"))); + assert!(!roots.contains(Path::new("/data/agentx/file"))); + assert!(!roots.contains(Path::new("relative/file"))); + } + + #[test] + fn a_project_root_must_be_an_absolute_existing_directory() { + let dir = std::env::temp_dir(); + assert_eq!(Roots::project(&dir).unwrap().cwd, dir); + assert!(Roots::project(Path::new("relative")).is_err()); + assert!(Roots::project(&dir.join("no-such-folder-ed-acp-tauri")).is_err()); + } +} diff --git a/crates/ed-acp/src/agent.rs b/crates/ed-acp/src/agent.rs index 156be25..4fb1cd0 100644 --- a/crates/ed-acp/src/agent.rs +++ b/crates/ed-acp/src/agent.rs @@ -15,7 +15,7 @@ use agent_client_protocol::schema::v1::{ ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, LogoutResponse, McpServer, MessageId, NewSessionRequest, NewSessionResponse, PromptRequest, - PromptResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, + PromptResponse, RequestId, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, SessionInfo, SessionInfoUpdate, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall, @@ -27,6 +27,7 @@ use tokio_util::sync::CancellationToken; use ed_mcp::{ClientInfo, McpToolset, ToolsetConfig}; +use crate::auth::{AuthCtx, AuthProvider}; use crate::content as prompt; use crate::llm::{Delta, LlmClient, LlmConfig, LlmEnv}; use crate::store::{SessionStore, StoredSession}; @@ -272,6 +273,8 @@ fn user_text(msg: &Value) -> String { #[derive(Clone)] pub struct AcpServer { profile: Arc, + /// The profile's own sign-in, when it has one (see [`Profile::auth`]). + auth: Option>, /// The profile's toolsets, built once. toolsets: Arc>>, env: LlmEnv, @@ -295,11 +298,16 @@ impl AcpServer { pub fn new(profile: Arc, yolo: bool, surface: &'static str) -> Self { let env = profile.info().env; let data_dir = env.data_dir(); + let auth = profile.auth(); Self { toolsets: Arc::new(profile.toolsets()), + llm: Arc::new(Mutex::new(LlmClient::new(Self::config_for( + auth.as_deref(), + &env, + )))), profile, + auth, env, - llm: Arc::new(Mutex::new(LlmClient::new(LlmConfig::from_env(&env)))), yolo, surface, client_caps: Arc::default(), @@ -310,6 +318,29 @@ impl AcpServer { } } + /// The provider's stored session when the profile has one, else the environment. A + /// signed-out provider yields the environment's endpoint with no key, so a stray + /// `ONDE_API_KEY` can't stand in for the product's own sign-in. + fn config_for(auth: Option<&dyn AuthProvider>, env: &LlmEnv) -> LlmConfig { + match auth { + None => LlmConfig::from_env(env), + Some(auth) => auth.llm_config().unwrap_or_else(|| LlmConfig { + api_key: None, + ..LlmConfig::from_env(env) + }), + } + } + + /// The auth methods for `initialize`. A provider's method is offered whatever the client + /// can do; terminal auth only to a client that can run it. + pub fn auth_methods(&self, caps: &ClientCapabilities) -> Vec { + match &self.auth { + Some(auth) => vec![auth.method()], + None if supports_terminal_auth(caps) => auth_methods(), + None => Vec::new(), + } + } + pub fn set_client_caps(&self, caps: ClientCapabilities) { *self.client_caps.lock().unwrap() = caps; } @@ -332,7 +363,7 @@ impl AcpServer { /// `authenticate`, so the stored key has to be picked up here. The model list is only /// dropped when the configuration actually changed. pub async fn refresh_credentials(&self) -> bool { - let fresh = LlmConfig::from_env(&self.env); + let fresh = Self::config_for(self.auth.as_deref(), &self.env); let changed = fresh != *self.llm().config(); if changed { tracing::info!( @@ -349,7 +380,30 @@ impl AcpServer { pub async fn authenticate( &self, req: AuthenticateRequest, + connection: &ConnectionTo, + request_id: RequestId, ) -> agent_client_protocol::Result { + if let Some(auth) = &self.auth { + if req.method_id != *auth.method().id() { + return Err(invalid(format!("unknown auth method {}", req.method_id.0))); + } + let caps = self.client_caps.lock().unwrap().clone(); + auth.authenticate(AuthCtx { + connection, + request_id, + client_caps: &caps, + }) + .await?; + // A new session means a new bearer, and possibly a different model list. + *self.models.lock().await = None; + *self.llm.lock().unwrap() = + LlmClient::new(Self::config_for(self.auth.as_deref(), &self.env)); + return if self.has_api_key() { + Ok(AuthenticateResponse::new()) + } else { + Err(self.auth_required()) + }; + } if &*req.method_id.0 != AUTH_METHOD_ID { return Err(invalid(format!("unknown auth method {}", req.method_id.0))); } @@ -365,6 +419,18 @@ impl AcpServer { /// the others. A key exported in the agent's own environment can't be removed from here /// and keeps the agent signed in. pub async fn logout(&self) -> agent_client_protocol::Result { + if let Some(auth) = &self.auth { + let result = auth.logout().await; + *self.models.lock().await = None; + *self.llm.lock().unwrap() = + LlmClient::new(Self::config_for(self.auth.as_deref(), &self.env)); + return match result { + Ok(()) => Ok(LogoutResponse::new()), + Err(e) => { + Err(agent_client_protocol::Error::internal_error().data(format!("{e:#}"))) + } + }; + } let failures: Vec = self .env .config_file_candidates() @@ -396,6 +462,12 @@ impl AcpServer { /// `AUTH_REQUIRED`, naming this agent's setup command. pub fn auth_required(&self) -> agent_client_protocol::Error { + if let Some(auth) = &self.auth { + return agent_client_protocol::Error::auth_required().data(format!( + "Not signed in. Authenticate with the {} method.", + auth.method().name() + )); + } auth_required_error(self.profile.info().name) } diff --git a/crates/ed-acp/src/auth.rs b/crates/ed-acp/src/auth.rs new file mode 100644 index 0000000..6137a3e --- /dev/null +++ b/crates/ed-acp/src/auth.rs @@ -0,0 +1,51 @@ +//! The auth seam for products with a sign-in of their own. +//! +//! Without a provider the agent only knows terminal auth: a client runs ` --setup` +//! and the key lands in the `env` file. A product whose credentials come from somewhere else +//! (a browser sign-in, a stored session) supplies an [`AuthProvider`] from +//! [`Profile::auth`](crate::Profile::auth) and the agent defers to it. + +use agent_client_protocol::schema::v1::{AuthMethod, ClientCapabilities, RequestId}; +use agent_client_protocol::{Client, ConnectionTo}; +use async_trait::async_trait; + +use crate::llm::LlmConfig; + +/// What `authenticate` may use to reach the user. +pub struct AuthCtx<'a> { + /// The client connection, for a URL-mode elicitation or a notification. + pub connection: &'a ConnectionTo, + /// The `authenticate` request's own id. No session exists yet, so it scopes an + /// elicitation sent while the request is open. + pub request_id: RequestId, + /// What the client advertised in `initialize`, e.g. `elicitation.url`. + pub client_caps: &'a ClientCapabilities, +} + +impl AuthCtx<'_> { + /// Whether the client can show a URL-mode elicitation. + pub fn client_offers_url_elicitation(&self) -> bool { + self.client_caps + .elicitation + .as_ref() + .is_some_and(|caps| caps.url.is_some()) + } +} + +#[async_trait] +pub trait AuthProvider: Send + Sync { + /// The method advertised in `initialize`, whatever the client's terminal capability, + /// e.g. `AuthMethod::Agent(AuthMethodAgent::new("sigit", "Sign in to siGit Code"))`. + fn method(&self) -> AuthMethod; + + /// The stored session as an endpoint, a bearer and a model, or `None` when signed out. + /// Read before each session request, so a sign-in finished in this process or another + /// is picked up without a restart. + fn llm_config(&self) -> Option; + + /// Run the sign-in. An existing session passes. An `Err` is shown to the user as is. + async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()>; + + /// Forget the stored session. + async fn logout(&self) -> anyhow::Result<()>; +} diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs index 97ba25e..0717357 100644 --- a/crates/ed-acp/src/lib.rs +++ b/crates/ed-acp/src/lib.rs @@ -33,6 +33,7 @@ //! ``` mod agent; +pub mod auth; pub mod cli; pub mod content; pub mod llm; @@ -51,6 +52,7 @@ use ed_mcp::{Connection, McpTool, McpToolset, SharedServer}; use serde_json::Value; pub use agent_client_protocol; +pub use auth::{AuthCtx, AuthProvider}; pub use content::AudioHints; pub use ed_mcp; pub use llm::{LlmConfig, LlmEnv, Provider}; @@ -143,6 +145,14 @@ pub trait Profile: Send + Sync + 'static { Vec::new() } + /// A sign-in of the product's own, for products whose credentials are not an API key in + /// the environment. With one, `initialize` advertises its method, the turn loop reads + /// its [`LlmConfig`], and `authenticate` and `logout` delegate to it. `None` keeps the + /// terminal `--setup` flow. + fn auth(&self) -> Option> { + None + } + /// Advertised in `initialize`. Audio is accepted only when this says so. fn prompt_capabilities(&self) -> PromptCapabilities { PromptCapabilities::new().embedded_context(true).image(true) diff --git a/crates/ed-acp/src/server.rs b/crates/ed-acp/src/server.rs index f558f84..c1ae88f 100644 --- a/crates/ed-acp/src/server.rs +++ b/crates/ed-acp/src/server.rs @@ -72,12 +72,7 @@ pub async fn serve( let agent = agent.clone(); async move |req: InitializeRequest, responder, _cx| { agent.set_client_caps(req.client_capabilities.clone()); - // Terminal auth is only usable by clients that can run it. - let methods = if agent::supports_terminal_auth(&req.client_capabilities) { - agent::auth_methods() - } else { - Vec::new() - }; + let methods = agent.auth_methods(&req.client_capabilities); responder.respond( InitializeResponse::new(agent::negotiate_version(req.protocol_version)) .auth_methods(methods) @@ -111,9 +106,13 @@ pub async fn serve( { let agent = agent.clone(); async move |req: AuthenticateRequest, responder, cx| { + // Spawned: a provider may wait on the client's answer to an elicitation, + // which the dispatch loop has to be free to deliver. let agent = agent.clone(); + let connection = cx.clone(); + let request_id = responder.id().clone(); cx.spawn(async move { - match agent.authenticate(req).await { + match agent.authenticate(req, &connection, request_id).await { Ok(resp) => responder.respond(resp), Err(e) => responder.respond_with_error(e), } diff --git a/crates/ed-acp/tests/in_process.rs b/crates/ed-acp/tests/in_process.rs index d6f0ee5..f9221d5 100644 --- a/crates/ed-acp/tests/in_process.rs +++ b/crates/ed-acp/tests/in_process.rs @@ -64,3 +64,146 @@ async fn initialize_over_an_in_memory_channel() { .expect("server task") .ok(); } + +mod provider_auth { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use agent_client_protocol::schema::ProtocolVersion; + use agent_client_protocol::schema::v1::{ + AuthMethod, AuthMethodAgent, AuthenticateRequest, InitializeRequest, LogoutRequest, + NewSessionRequest, + }; + use agent_client_protocol::{Agent, Channel, Client, ConnectionTo}; + use async_trait::async_trait; + use ed_acp::{ + AgentInfo, AuthCtx, AuthProvider, LlmConfig, LlmEnv, Profile, PromptCtx, Provider, + ServeOptions, Toolset, + }; + + /// A provider whose "session" is a string it holds in memory. + #[derive(Default)] + struct State { + token: Mutex>, + authenticated: AtomicUsize, + } + + struct Fake(Arc); + + #[async_trait] + impl AuthProvider for Fake { + fn method(&self) -> AuthMethod { + AuthMethod::Agent(AuthMethodAgent::new("fake", "Sign in to Fake")) + } + + fn llm_config(&self) -> Option { + self.0.token.lock().unwrap().clone().map(|token| LlmConfig { + provider: Provider::OpenAi, + // Nothing listens here: listing models fails fast and the default is offered. + base_url: "http://127.0.0.1:1".into(), + api_key: Some(token), + model: "fake-model".into(), + }) + } + + async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()> { + assert!(!ctx.client_offers_url_elicitation()); + self.0.authenticated.fetch_add(1, Ordering::SeqCst); + *self.0.token.lock().unwrap() = Some("token".into()); + Ok(()) + } + + async fn logout(&self) -> anyhow::Result<()> { + *self.0.token.lock().unwrap() = None; + Ok(()) + } + } + + struct WithAuth(Arc); + + impl Profile for WithAuth { + fn info(&self) -> AgentInfo { + AgentInfo { + name: "provider-agent", + display_name: "Provider", + version: "0.0.1", + env: LlmEnv { + prefix: "ED_ACP_PROVIDER_TEST", + dir_name: "ed-acp-provider-test", + default_model: "fake-model", + }, + } + } + + fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String { + String::new() + } + + fn toolsets(&self) -> Vec> { + Vec::new() + } + + fn auth(&self) -> Option> { + Some(Arc::new(Fake(self.0.clone()))) + } + } + + #[tokio::test] + async fn the_provider_owns_initialize_sessions_authenticate_and_logout() { + let fake = Arc::new(State::default()); + let (agent_end, client_end) = Channel::duplex(); + let opts = ServeOptions { + yolo: false, + surface: "app", + }; + tokio::spawn(ed_acp::serve( + Arc::new(WithAuth(fake.clone())), + opts, + agent_end, + )); + let cwd = std::env::temp_dir(); + + Client + .builder() + .connect_with(client_end, |conn: ConnectionTo| async move { + // The client cannot run terminal auth, and the method is offered anyway. + let init = conn + .send_request(InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await?; + assert_eq!(init.auth_methods.len(), 1); + assert_eq!(&*init.auth_methods[0].id().0, "fake"); + + let signed_out = conn + .send_request(NewSessionRequest::new(cwd.clone())) + .block_task() + .await; + assert!(signed_out.is_err(), "no session while signed out"); + + let wrong = conn + .send_request(AuthenticateRequest::new("terminal-setup")) + .block_task() + .await; + assert!(wrong.is_err(), "only the provider's method is accepted"); + + conn.send_request(AuthenticateRequest::new("fake")) + .block_task() + .await?; + conn.send_request(NewSessionRequest::new(cwd.clone())) + .block_task() + .await?; + + conn.send_request(LogoutRequest::new()).block_task().await?; + let after = conn + .send_request(NewSessionRequest::new(cwd.clone())) + .block_task() + .await; + assert!(after.is_err(), "signed out again"); + Ok(()) + }) + .await + .expect("conversation"); + + assert_eq!(fake.authenticated.load(Ordering::SeqCst), 1); + } +} From b224562206ca4d63dbd53a435e9289dafcd3e712 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:57:24 +0200 Subject: [PATCH 04/10] ed-mcp: test a hook can cancel an elicitation Covers the Cancel action path in the elicitation hook, asserting the result text is "cancelled" and the hook is invoked once. Co-Authored-By: siGit Code v1.6.6-acp --- crates/ed-mcp/tests/http.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ed-mcp/tests/http.rs b/crates/ed-mcp/tests/http.rs index aa2ecd0..5e917ac 100644 --- a/crates/ed-mcp/tests/http.rs +++ b/crates/ed-mcp/tests/http.rs @@ -343,6 +343,39 @@ async fn forwards_server_elicitation_to_the_hook() { set.shutdown().await; } +#[tokio::test] +async fn a_hook_can_cancel_an_elicitation() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + + // A hook that cancels round-trips distinctly from one that declines: `create_elicitation` + // forwards the hook's `ElicitResult` verbatim, so the server sees Cancel, not Decline. + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let hook = Arc::new(Recorder { + seen: seen.clone(), + answer: ElicitResult::new(ElicitationAction::Cancel), + }); + let config = ToolsetConfig::new(client()).with_elicitation(hook); + let set = McpToolset::connect_specs(config, &[spec], &[]).await; + + let out = set + .call( + "mcp__mail__collect_feedback", + json!({}), + &CancellationToken::new(), + &|_| {}, + ) + .await + .unwrap(); + + // The hook ran and the server mapped Cancel to "cancelled", distinct from "declined". + assert!(!out.failed, "{}", out.text); + assert_eq!(out.text, "cancelled"); + assert_eq!(seen.lock().unwrap().len(), 1, "the hook was called once"); + + set.shutdown().await; +} + #[tokio::test] async fn without_a_hook_server_elicitation_is_declined() { let url = serve().await; From caffb74842bc866fed80c22f48a33b90242036c0 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:28:43 +0200 Subject: [PATCH 05/10] ed-acp: reset models/llm only after successful logout Previously logout() cleared the models cache and llm config before checking the result of auth.logout(); a failed logout left the agent in an inconsistent state. Move the resets inside the Ok(()) arm so state is only cleared when logout actually succeeds. Co-Authored-By: siGit Code v1.6.7-acp --- crates/ed-acp/src/agent.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ed-acp/src/agent.rs b/crates/ed-acp/src/agent.rs index 4fb1cd0..3bb5bce 100644 --- a/crates/ed-acp/src/agent.rs +++ b/crates/ed-acp/src/agent.rs @@ -420,12 +420,13 @@ impl AcpServer { /// and keeps the agent signed in. pub async fn logout(&self) -> agent_client_protocol::Result { if let Some(auth) = &self.auth { - let result = auth.logout().await; - *self.models.lock().await = None; - *self.llm.lock().unwrap() = - LlmClient::new(Self::config_for(self.auth.as_deref(), &self.env)); - return match result { - Ok(()) => Ok(LogoutResponse::new()), + return match auth.logout().await { + Ok(()) => { + *self.models.lock().await = None; + *self.llm.lock().unwrap() = + LlmClient::new(Self::config_for(self.auth.as_deref(), &self.env)); + Ok(LogoutResponse::new()) + } Err(e) => { Err(agent_client_protocol::Error::internal_error().data(format!("{e:#}"))) } From 503bbd80c193be3747fdb2dddb1e83eeb5b612a2 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:00:28 +0200 Subject: [PATCH 06/10] Release 1.3.0: bump the workspace Minor: ed-acp gains Profile::auth() (default impl, so existing profiles keep compiling) and the AuthProvider/AuthCtx types behind it, and ed-mcp gains the Elicitation trait, ToolsetConfig::with_elicitation, and ToolsetConfig::elicitation. ed-acp-tauri is a new crate and gets its first publish. This closes the gap that broke `cargo publish -p ed-acp-tauri`: the 1.2.2 already on crates.io predates Profile::auth(), so ed-acp-tauri's ed-acp = "1.2.2" dependency resolved against a registry copy without it during publish verification. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 16 ++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5c4c7c..6cdfd86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1995,7 +1995,7 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed-acp" -version = "1.2.2" +version = "1.3.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "ed-acp-tauri" -version = "1.2.2" +version = "1.3.0" dependencies = [ "agent-client-protocol", "ed-acp", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.2.2" +version = "1.3.0" dependencies = [ "serde_json", "tokio", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.2.2" +version = "1.3.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -2055,7 +2055,7 @@ dependencies = [ [[package]] name = "ed-agent" -version = "1.2.2" +version = "1.3.0" dependencies = [ "async-trait", "futures-core", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.2.2" +version = "1.3.0" dependencies = [ "async-trait", "cc", @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "ed-agent-tauri" -version = "1.2.2" +version = "1.3.0" dependencies = [ "async-trait", "ed-agent", @@ -2098,7 +2098,7 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.2.2" +version = "1.3.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -2117,7 +2117,7 @@ dependencies = [ [[package]] name = "ed-onde-account" -version = "1.2.2" +version = "1.3.0" dependencies = [ "axum", "reqwest 0.13.5", diff --git a/Cargo.toml b/Cargo.toml index e3ee1ee..9725927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.2.2" +version = "1.3.0" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" @@ -59,13 +59,13 @@ directories = "6" base64 = "0.23" # Internal -ed-agent = { version = "1.2.2", path = "crates/ed-agent" } -ed-mcp = { version = "1.2.2", path = "crates/ed-mcp" } -ed-acp = { version = "1.2.2", path = "crates/ed-acp" } -ed-acp-tauri = { version = "1.2.2", path = "crates/ed-acp-tauri" } -ed-acp-tui = { version = "1.2.2", path = "crates/ed-acp-tui" } -ed-acp-testkit = { version = "1.2.2", path = "crates/ed-acp-testkit" } -ed-onde-account = { version = "1.2.2", path = "crates/ed-onde-account" } +ed-agent = { version = "1.3.0", path = "crates/ed-agent" } +ed-mcp = { version = "1.3.0", path = "crates/ed-mcp" } +ed-acp = { version = "1.3.0", path = "crates/ed-acp" } +ed-acp-tauri = { version = "1.3.0", path = "crates/ed-acp-tauri" } +ed-acp-tui = { version = "1.3.0", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.3.0", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "1.3.0", path = "crates/ed-onde-account" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind From 8e12a74e7029ecde0bdb8f74dde455a499d10fbf Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:20:20 +0200 Subject: [PATCH 07/10] ed-mcp: skip the child-process transport on tvOS tvOS has no exec, so rmcp's transport-child-process feature pulls in nix, which does not compile there. Move that feature into a cfg(not(target_os = "tvos")) dependency and gate the stdio transport arm the same way. On tvOS a stdio MCP server now fails with a plain message instead of the crate refusing to build. cargo +nightly rustc -Z build-std -p ed-mcp --target aarch64-apple-tvos now compiles; the HTTP transport and the rest of the workspace are unchanged. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.9 --- crates/ed-mcp/Cargo.toml | 8 +++++++- crates/ed-mcp/src/lib.rs | 13 ++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/ed-mcp/Cargo.toml b/crates/ed-mcp/Cargo.toml index 550b68a..f2234e2 100644 --- a/crates/ed-mcp/Cargo.toml +++ b/crates/ed-mcp/Cargo.toml @@ -16,7 +16,7 @@ agent-client-protocol = { workspace = true, optional = true } ed-agent = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } http = "1" -rmcp = { workspace = true, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] } +rmcp = { workspace = true, features = ["client", "transport-streamable-http-client-reqwest", "reqwest"] } tokio = { workspace = true, features = ["sync", "time", "process", "macros", "io-std", "io-util", "rt"] } tokio-util.workspace = true futures.workspace = true @@ -24,6 +24,12 @@ serde_json.workspace = true anyhow.workspace = true tracing.workspace = true +# Spawning an MCP server as a child process needs `exec`, which Apple hides on tvOS (`libc` +# has no `execv` there, so `nix` under `process-wrap` doesn't compile). The stdio transport +# is everywhere else. +[target.'cfg(not(target_os = "tvos"))'.dependencies] +rmcp = { workspace = true, features = ["transport-child-process"] } + [features] # Map ACP `session/new` MCP server lists (`McpToolset::connect`, `ServerSpec::from_acp`). # Pulls in `agent-client-protocol`; `ed-acp` enables it. diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs index 1ca0396..ce12b1f 100644 --- a/crates/ed-mcp/src/lib.rs +++ b/crates/ed-mcp/src/lib.rs @@ -34,8 +34,10 @@ use rmcp::model::{ Implementation, ProgressNotificationParam, Request, ServerResult, Tool, }; use rmcp::service::{NotificationContext, PeerRequestOptions, RequestContext, RunningService}; +use rmcp::transport::StreamableHttpClientTransport; +#[cfg(not(target_os = "tvos"))] +use rmcp::transport::TokioChildProcess; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; -use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess}; use rmcp::{ClientHandler, ErrorData as McpError, RoleClient, ServiceExt}; use serde_json::{Map, Value, json}; use tokio::sync::Mutex; @@ -332,6 +334,7 @@ impl Connection { elicitation, }; let handshake = match &spec.transport { + #[cfg(not(target_os = "tvos"))] Transport::Stdio { command, args, env } => { let mut cmd = tokio::process::Command::new(command); cmd.args(args).envs(env.iter().map(|(k, v)| (k, v))); @@ -339,6 +342,14 @@ impl Connection { .with_context(|| format!("starting {}", command.display()))?; tokio::time::timeout(timeout, handler.serve(transport)).await } + // tvOS can't start a process, so there is no stdio transport to offer. + #[cfg(target_os = "tvos")] + Transport::Stdio { command, .. } => { + bail!( + "can't start {}: this platform can't run MCP servers as processes", + command.display() + ); + } Transport::Http { url, bearer, From 5e97a83559766be7a170e7edb67f65cdfc01fe64 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:34:55 +0200 Subject: [PATCH 08/10] ed-mcp: document the tvOS stdio limitation in the public API Address siGit Code Review feedback on PR #37: callers building an MCP client on tvOS see Transport::Stdio as a regular variant and only learn that the OS forbids child processes when the runtime errors out. Surface that in the rustdoc so the limitation is discoverable before shipping. - Transport::Stdio: note that tvOS cannot spawn child processes and point at Transport::Http as the supported alternative. - Connection::connect / connect_with: add # Errors sections calling out the tvOS-specific failure and the per-stage timeout for spawn, handshake and tools/list on every platform. Co-Authored-By: siGit Code v1.6.8-acp --- crates/ed-mcp/src/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs index ce12b1f..110c0c2 100644 --- a/crates/ed-mcp/src/lib.rs +++ b/crates/ed-mcp/src/lib.rs @@ -142,6 +142,14 @@ impl ClientInfo { #[derive(Clone)] pub enum Transport { /// Start `command args…` with extra environment variables and talk over its stdio. + /// + /// Stdio is only available on platforms that can spawn a child process. + /// On `target_os = "tvos"` the OS does not allow apps to create other + /// processes, so this variant is **not** supported on tvOS builds: the + /// matching arm in [`Connection::connect_with`] returns an error at + /// runtime instead of compiling out the variant. Use [`Transport::Http`] + /// for tvOS; if you only know the transport at runtime, branch on the + /// current `target_os` before constructing a `Stdio` spec. Stdio { command: PathBuf, args: Vec, @@ -315,12 +323,30 @@ pub struct Connection { impl Connection { /// Connect to `spec`, run the handshake and list its tools, each within `timeout`. + /// + /// # Errors + /// + /// - On tvOS, connecting a `Transport::Stdio` spec always fails with a + /// "this platform can't run MCP servers as processes" error — tvOS does + /// not allow child processes. Use [`Transport::Http`] on that target. + /// - On every platform, spawning the child, completing the handshake and + /// listing tools are each independently bounded by `timeout` and may + /// fail or time out. pub async fn connect(spec: &ServerSpec, info: &ClientInfo, timeout: Duration) -> Result { Self::connect_with(spec, info, None, timeout).await } /// Connect to `spec`, with an optional [`Elicitation`] hook for server-initiated /// `elicitation/create` requests. [`Connection::connect`] is this with no hook. + /// + /// # Errors + /// + /// - On tvOS, connecting a `Transport::Stdio` spec always fails with a + /// "this platform can't run MCP servers as processes" error — tvOS does + /// not allow child processes. Use [`Transport::Http`] on that target. + /// - On every platform, spawning the child, completing the handshake and + /// listing tools are each independently bounded by `timeout` and may + /// fail or time out. pub async fn connect_with( spec: &ServerSpec, info: &ClientInfo, From db0039c6dd97a5c4332cbd32d5652c8e9479a50f Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:17:22 +0200 Subject: [PATCH 09/10] Skip the stdio child-process transport on watchOS watchOS hides execv the same way tvOS does, so nix under process-wrap doesn't compile there. Gate the rmcp child-process feature, the TokioChildProcess import and the Transport::Stdio arm on any(tvos, watchos). Http stays available on both. Co-Authored-By: Claude Sonnet 5.5 --- crates/ed-mcp/Cargo.toml | 8 ++++---- crates/ed-mcp/src/lib.rs | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/ed-mcp/Cargo.toml b/crates/ed-mcp/Cargo.toml index f2234e2..7a3ce14 100644 --- a/crates/ed-mcp/Cargo.toml +++ b/crates/ed-mcp/Cargo.toml @@ -24,10 +24,10 @@ serde_json.workspace = true anyhow.workspace = true tracing.workspace = true -# Spawning an MCP server as a child process needs `exec`, which Apple hides on tvOS (`libc` -# has no `execv` there, so `nix` under `process-wrap` doesn't compile). The stdio transport -# is everywhere else. -[target.'cfg(not(target_os = "tvos"))'.dependencies] +# Spawning an MCP server as a child process needs `exec`, which Apple hides on tvOS and +# watchOS (`libc` has no `execv` there, so `nix` under `process-wrap` doesn't compile). The +# stdio transport is everywhere else. +[target.'cfg(not(any(target_os = "tvos", target_os = "watchos")))'.dependencies] rmcp = { workspace = true, features = ["transport-child-process"] } [features] diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs index 110c0c2..07f9feb 100644 --- a/crates/ed-mcp/src/lib.rs +++ b/crates/ed-mcp/src/lib.rs @@ -35,7 +35,7 @@ use rmcp::model::{ }; use rmcp::service::{NotificationContext, PeerRequestOptions, RequestContext, RunningService}; use rmcp::transport::StreamableHttpClientTransport; -#[cfg(not(target_os = "tvos"))] +#[cfg(not(any(target_os = "tvos", target_os = "watchos")))] use rmcp::transport::TokioChildProcess; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; use rmcp::{ClientHandler, ErrorData as McpError, RoleClient, ServiceExt}; @@ -144,12 +144,12 @@ pub enum Transport { /// Start `command args…` with extra environment variables and talk over its stdio. /// /// Stdio is only available on platforms that can spawn a child process. - /// On `target_os = "tvos"` the OS does not allow apps to create other - /// processes, so this variant is **not** supported on tvOS builds: the - /// matching arm in [`Connection::connect_with`] returns an error at - /// runtime instead of compiling out the variant. Use [`Transport::Http`] - /// for tvOS; if you only know the transport at runtime, branch on the - /// current `target_os` before constructing a `Stdio` spec. + /// On `target_os = "tvos"` and `target_os = "watchos"` the OS does not allow + /// apps to create other processes, so this variant is **not** supported on + /// tvOS or watchOS builds: the matching arm in [`Connection::connect_with`] + /// returns an error at runtime instead of compiling out the variant. Use + /// [`Transport::Http`] there; if you only know the transport at runtime, + /// branch on the current `target_os` before constructing a `Stdio` spec. Stdio { command: PathBuf, args: Vec, @@ -360,7 +360,7 @@ impl Connection { elicitation, }; let handshake = match &spec.transport { - #[cfg(not(target_os = "tvos"))] + #[cfg(not(any(target_os = "tvos", target_os = "watchos")))] Transport::Stdio { command, args, env } => { let mut cmd = tokio::process::Command::new(command); cmd.args(args).envs(env.iter().map(|(k, v)| (k, v))); @@ -368,8 +368,8 @@ impl Connection { .with_context(|| format!("starting {}", command.display()))?; tokio::time::timeout(timeout, handler.serve(transport)).await } - // tvOS can't start a process, so there is no stdio transport to offer. - #[cfg(target_os = "tvos")] + // tvOS and watchOS can't start a process, so there is no stdio transport to offer. + #[cfg(any(target_os = "tvos", target_os = "watchos"))] Transport::Stdio { command, .. } => { bail!( "can't start {}: this platform can't run MCP servers as processes", From fddf643a28fd3d0ee595abf2f292a0458bd9c4d7 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:34:52 +0200 Subject: [PATCH 10/10] Release 1.3.2: bump the workspace Co-Authored-By: siGit Code v1.7.0-acp --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 16 ++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cdfd86..164f337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1995,7 +1995,7 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed-acp" -version = "1.3.0" +version = "1.3.2" dependencies = [ "agent-client-protocol", "anyhow", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "ed-acp-tauri" -version = "1.3.0" +version = "1.3.2" dependencies = [ "agent-client-protocol", "ed-acp", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.3.0" +version = "1.3.2" dependencies = [ "serde_json", "tokio", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.3.0" +version = "1.3.2" dependencies = [ "agent-client-protocol", "anyhow", @@ -2055,7 +2055,7 @@ dependencies = [ [[package]] name = "ed-agent" -version = "1.3.0" +version = "1.3.2" dependencies = [ "async-trait", "futures-core", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.3.0" +version = "1.3.2" dependencies = [ "async-trait", "cc", @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "ed-agent-tauri" -version = "1.3.0" +version = "1.3.2" dependencies = [ "async-trait", "ed-agent", @@ -2098,7 +2098,7 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.3.0" +version = "1.3.2" dependencies = [ "agent-client-protocol", "anyhow", @@ -2117,7 +2117,7 @@ dependencies = [ [[package]] name = "ed-onde-account" -version = "1.3.0" +version = "1.3.2" dependencies = [ "axum", "reqwest 0.13.5", diff --git a/Cargo.toml b/Cargo.toml index 9725927..bfeaf77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.3.0" +version = "1.3.2" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" @@ -59,13 +59,13 @@ directories = "6" base64 = "0.23" # Internal -ed-agent = { version = "1.3.0", path = "crates/ed-agent" } -ed-mcp = { version = "1.3.0", path = "crates/ed-mcp" } -ed-acp = { version = "1.3.0", path = "crates/ed-acp" } -ed-acp-tauri = { version = "1.3.0", path = "crates/ed-acp-tauri" } -ed-acp-tui = { version = "1.3.0", path = "crates/ed-acp-tui" } -ed-acp-testkit = { version = "1.3.0", path = "crates/ed-acp-testkit" } -ed-onde-account = { version = "1.3.0", path = "crates/ed-onde-account" } +ed-agent = { version = "1.3.2", path = "crates/ed-agent" } +ed-mcp = { version = "1.3.2", path = "crates/ed-mcp" } +ed-acp = { version = "1.3.2", path = "crates/ed-acp" } +ed-acp-tauri = { version = "1.3.2", path = "crates/ed-acp-tauri" } +ed-acp-tui = { version = "1.3.2", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.3.2", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "1.3.2", path = "crates/ed-onde-account" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind