@ondeinference / ed / commits / 7cdb521

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 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.7 <noreply@sigit.si>

Seto Elkahfi committed Oct 12, 2026 at 08:40 UTC 7cdb5215a3b886c01b02cbcbf8fe494df418f21e
11 files changed +1568 -10
.agents/AGENTS.md
+7
@@ -13,6 +13,7 @@ into focused crates; do not merge them.
13 |---|---|
14 | `ed-agent` | Core library: `Ed` struct, lifecycle, `send`/`stream`/`run`, tool approval, `EventSink`, `SessionStore` seams |
15 | `ed-acp` | ACP v1 server: `Profile` trait, `serve`/`serve_stdio`, sessions, LLM streaming, tool routing, MCP |
16 +| `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 |
17 | `ed-mcp` | MCP integration over `rmcp`: tool namespacing (`mcp__<server>__<tool>`, max 64 bytes), `SharedServer`, `McpExecutor`, timeouts, cancellation |
18 | `ed-acp-tui` | ratatui TUI frontend for ACP agents over stdio |
19 | `ed-acp-testkit` | Mock Onde Cloud endpoint + conformance test suite |
@@ -45,6 +46,12 @@ into focused crates; do not merge them.
46
47 6. **Tauri and FFI are separate bindings.** `ed-agent-tauri` and
48 `ed-agent-ffi` both wrap `ed-agent` but must not depend on each other.
49 + `ed-acp-tauri` is the Tauri host for `ed-acp` agents; it does not depend on
50 + `ed-agent-tauri`.
51 +
52 +7. **A product's own sign-in goes through `Profile::auth`.** `ed-acp` knows
53 + terminal auth and the `<PREFIX>_*` env file. A product with a browser or
54 + session sign-in supplies an `AuthProvider`; do not put its flow in `ed-acp`.
55
56 ---
57
Cargo.lock
+260
@@ -322,6 +322,18 @@ dependencies = [
322 "winnow 0.7.15",
323 ]
324
325 +[[package]]
326 +name = "async-broadcast"
327 +version = "0.7.2"
328 +source = "registry+https://github.com/rust-lang/crates.io-index"
329 +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
330 +dependencies = [
331 + "event-listener",
332 + "event-listener-strategy",
333 + "futures-core",
334 + "pin-project-lite",
335 +]
336 +
337 [[package]]
338 name = "async-channel"
339 version = "2.5.0"
@@ -347,6 +359,20 @@ dependencies = [
359 "tokio",
360 ]
361
362 +[[package]]
363 +name = "async-executor"
364 +version = "1.14.0"
365 +source = "registry+https://github.com/rust-lang/crates.io-index"
366 +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
367 +dependencies = [
368 + "async-task",
369 + "concurrent-queue",
370 + "fastrand",
371 + "futures-lite",
372 + "pin-project-lite",
373 + "slab",
374 +]
375 +
376 [[package]]
377 name = "async-io"
378 version = "2.6.0"
@@ -394,6 +420,17 @@ dependencies = [
420 "rustix",
421 ]
422
423 +[[package]]
424 +name = "async-recursion"
425 +version = "1.2.0"
426 +source = "registry+https://github.com/rust-lang/crates.io-index"
427 +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
428 +dependencies = [
429 + "proc-macro2",
430 + "quote",
431 + "syn 3.0.6",
432 +]
433 +
434 [[package]]
435 name = "async-signal"
436 version = "0.2.14"
@@ -1979,6 +2016,22 @@ dependencies = [
2016 "uuid 1.27.0",
2017 ]
2018
2019 +[[package]]
2020 +name = "ed-acp-tauri"
2021 +version = "1.2.2"
2022 +dependencies = [
2023 + "agent-client-protocol",
2024 + "ed-acp",
2025 + "log",
2026 + "serde",
2027 + "serde_json",
2028 + "tauri",
2029 + "tauri-plugin-opener",
2030 + "tokio",
2031 + "tsync",
2032 + "uuid 1.27.0",
2033 +]
2034 +
2035 [[package]]
2036 name = "ed-acp-testkit"
2037 version = "1.2.2"
@@ -2145,6 +2198,12 @@ dependencies = [
2198 "encoding_rs",
2199 ]
2200
2201 +[[package]]
2202 +name = "endi"
2203 +version = "1.1.1"
2204 +source = "registry+https://github.com/rust-lang/crates.io-index"
2205 +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
2206 +
2207 [[package]]
2208 name = "endian-type"
2209 version = "0.2.0"
@@ -2170,6 +2229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2229 checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
2230 dependencies = [
2231 "enumflags2_derive",
2232 + "serde",
2233 ]
2234
2235 [[package]]
@@ -3760,6 +3820,25 @@ version = "2.12.2"
3820 source = "registry+https://github.com/rust-lang/crates.io-index"
3821 checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
3822
3823 +[[package]]
3824 +name = "is-docker"
3825 +version = "0.2.0"
3826 +source = "registry+https://github.com/rust-lang/crates.io-index"
3827 +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
3828 +dependencies = [
3829 + "once_cell",
3830 +]
3831 +
3832 +[[package]]
3833 +name = "is-wsl"
3834 +version = "0.4.0"
3835 +source = "registry+https://github.com/rust-lang/crates.io-index"
3836 +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
3837 +dependencies = [
3838 + "is-docker",
3839 + "once_cell",
3840 +]
3841 +
3842 [[package]]
3843 name = "is_terminal_polyfill"
3844 version = "1.70.2"
@@ -5401,6 +5480,17 @@ dependencies = [
5480 "pkg-config",
5481 ]
5482
5483 +[[package]]
5484 +name = "open"
5485 +version = "5.4.4"
5486 +source = "registry+https://github.com/rust-lang/crates.io-index"
5487 +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
5488 +dependencies = [
5489 + "dunce",
5490 + "is-wsl",
5491 + "libc",
5492 +]
5493 +
5494 [[package]]
5495 name = "openai-harmony"
5496 version = "0.0.8"
@@ -5446,6 +5536,16 @@ dependencies = [
5536 "num-traits",
5537 ]
5538
5539 +[[package]]
5540 +name = "ordered-stream"
5541 +version = "0.2.0"
5542 +source = "registry+https://github.com/rust-lang/crates.io-index"
5543 +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
5544 +dependencies = [
5545 + "futures-core",
5546 + "pin-project-lite",
5547 +]
5548 +
5549 [[package]]
5550 name = "packedvec"
5551 version = "1.2.5"
@@ -8140,6 +8240,44 @@ dependencies = [
8240 "tauri-utils",
8241 ]
8242
8243 +[[package]]
8244 +name = "tauri-plugin"
8245 +version = "2.7.1"
8246 +source = "registry+https://github.com/rust-lang/crates.io-index"
8247 +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2"
8248 +dependencies = [
8249 + "anyhow",
8250 + "glob",
8251 + "plist",
8252 + "schemars 0.8.22",
8253 + "serde",
8254 + "serde_json",
8255 + "tauri-utils",
8256 + "walkdir",
8257 +]
8258 +
8259 +[[package]]
8260 +name = "tauri-plugin-opener"
8261 +version = "2.7.1"
8262 +source = "registry+https://github.com/rust-lang/crates.io-index"
8263 +checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8"
8264 +dependencies = [
8265 + "dunce",
8266 + "glob",
8267 + "objc2-app-kit",
8268 + "objc2-foundation",
8269 + "open",
8270 + "schemars 0.8.22",
8271 + "serde",
8272 + "serde_json",
8273 + "tauri",
8274 + "tauri-plugin",
8275 + "thiserror 2.0.21",
8276 + "url",
8277 + "windows 0.62.2",
8278 + "zbus",
8279 +]
8280 +
8281 [[package]]
8282 name = "tauri-runtime"
8283 version = "2.12.1"
@@ -8914,6 +9052,17 @@ version = "1.20.1"
9052 source = "registry+https://github.com/rust-lang/crates.io-index"
9053 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
9054
9055 +[[package]]
9056 +name = "uds_windows"
9057 +version = "1.2.1"
9058 +source = "registry+https://github.com/rust-lang/crates.io-index"
9059 +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
9060 +dependencies = [
9061 + "memoffset",
9062 + "tempfile",
9063 + "windows-sys 0.61.2",
9064 +]
9065 +
9066 [[package]]
9067 name = "ug"
9068 version = "0.5.0"
@@ -10367,6 +10516,76 @@ dependencies = [
10516 "synstructure 0.14.0",
10517 ]
10518
10519 +[[package]]
10520 +name = "zbus"
10521 +version = "5.19.0"
10522 +source = "registry+https://github.com/rust-lang/crates.io-index"
10523 +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
10524 +dependencies = [
10525 + "async-broadcast",
10526 + "async-executor",
10527 + "async-io",
10528 + "async-lock",
10529 + "async-process",
10530 + "async-recursion",
10531 + "async-task",
10532 + "async-trait",
10533 + "blocking",
10534 + "enumflags2",
10535 + "event-listener",
10536 + "futures-core",
10537 + "futures-lite",
10538 + "hex",
10539 + "libc",
10540 + "ordered-stream",
10541 + "rustix",
10542 + "serde",
10543 + "serde_repr",
10544 + "tracing",
10545 + "uds_windows",
10546 + "uuid 1.27.0",
10547 + "windows-sys 0.61.2",
10548 + "winnow 1.0.4",
10549 + "zbus_macros",
10550 + "zbus_names",
10551 + "zvariant",
10552 +]
10553 +
10554 +[[package]]
10555 +name = "zbus_macros"
10556 +version = "5.19.0"
10557 +source = "registry+https://github.com/rust-lang/crates.io-index"
10558 +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
10559 +dependencies = [
10560 + "proc-macro-crate 3.5.0",
10561 + "proc-macro2",
10562 + "quote",
10563 + "syn 3.0.6",
10564 + "zbus_names",
10565 + "zvariant",
10566 + "zvariant_utils",
10567 +]
10568 +
10569 +[[package]]
10570 +name = "zbus_names"
10571 +version = "4.3.4"
10572 +source = "registry+https://github.com/rust-lang/crates.io-index"
10573 +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
10574 +dependencies = [
10575 + "serde",
10576 + "winnow 1.0.4",
10577 + "zvariant",
10578 +]
10579 +
10580 +[[package]]
10581 +name = "zcheapstr"
10582 +version = "1.1.0"
10583 +source = "registry+https://github.com/rust-lang/crates.io-index"
10584 +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
10585 +dependencies = [
10586 + "serde",
10587 +]
10588 +
10589 [[package]]
10590 name = "zerocopy"
10591 version = "0.8.59"
@@ -10494,3 +10713,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
10713 dependencies = [
10714 "zune-core",
10715 ]
10716 +
10717 +[[package]]
10718 +name = "zvariant"
10719 +version = "5.15.0"
10720 +source = "registry+https://github.com/rust-lang/crates.io-index"
10721 +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
10722 +dependencies = [
10723 + "endi",
10724 + "enumflags2",
10725 + "serde",
10726 + "winnow 1.0.4",
10727 + "zcheapstr",
10728 + "zvariant_derive",
10729 + "zvariant_utils",
10730 +]
10731 +
10732 +[[package]]
10733 +name = "zvariant_derive"
10734 +version = "5.15.0"
10735 +source = "registry+https://github.com/rust-lang/crates.io-index"
10736 +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
10737 +dependencies = [
10738 + "proc-macro-crate 3.5.0",
10739 + "proc-macro2",
10740 + "quote",
10741 + "syn 3.0.6",
10742 + "zvariant_utils",
10743 +]
10744 +
10745 +[[package]]
10746 +name = "zvariant_utils"
10747 +version = "4.2.0"
10748 +source = "registry+https://github.com/rust-lang/crates.io-index"
10749 +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
10750 +dependencies = [
10751 + "proc-macro2",
10752 + "quote",
10753 + "serde",
10754 + "syn 3.0.6",
10755 + "winnow 1.0.4",
10756 +]
Cargo.toml
+2
@@ -6,6 +6,7 @@ members = [
6 "crates/ed-agent-tauri",
7 "crates/ed-mcp",
8 "crates/ed-acp",
9 + "crates/ed-acp-tauri",
10 "crates/ed-acp-tui",
11 "crates/ed-acp-testkit",
12 "crates/ed-onde-account",
@@ -61,6 +62,7 @@ base64 = "0.23"
62 ed-agent = { version = "1.2.2", path = "crates/ed-agent" }
63 ed-mcp = { version = "1.2.2", path = "crates/ed-mcp" }
64 ed-acp = { version = "1.2.2", path = "crates/ed-acp" }
65 +ed-acp-tauri = { version = "1.2.2", path = "crates/ed-acp-tauri" }
66 ed-acp-tui = { version = "1.2.2", path = "crates/ed-acp-tui" }
67 ed-acp-testkit = { version = "1.2.2", path = "crates/ed-acp-testkit" }
68 ed-onde-account = { version = "1.2.2", path = "crates/ed-onde-account" }
crates/ed-acp-tauri/Cargo.toml new
+24
@@ -0,0 +1,24 @@
1 +[package]
2 +name = "ed-acp-tauri"
3 +description = "Hosts an ed-acp agent inside a Tauri app: in-process ACP serve, permissions, sign-in, sessions and the webview events."
4 +keywords = ["tauri", "acp", "agent", "onde", "mcp"]
5 +categories = ["development-tools", "gui"]
6 +version.workspace = true
7 +edition.workspace = true
8 +rust-version.workspace = true
9 +license.workspace = true
10 +authors.workspace = true
11 +repository.workspace = true
12 +homepage.workspace = true
13 +
14 +[dependencies]
15 +ed-acp.workspace = true
16 +agent-client-protocol.workspace = true
17 +tauri.workspace = true
18 +tauri-plugin-opener = "2"
19 +log.workspace = true
20 +serde.workspace = true
21 +serde_json.workspace = true
22 +tokio = { workspace = true, features = ["sync", "rt"] }
23 +tsync = "2"
24 +uuid.workspace = true
crates/ed-acp-tauri/src/client.rs new
+797
@@ -0,0 +1,797 @@
1 +//! The ACP client side, and the agent it serves in-process.
2 +//!
3 +//! [`AgentState::connection`] starts both halves on first use:
4 +//!
5 +//! - `ed_acp::serve(profile)` on one end of a [`Channel::duplex`], exactly what the product's
6 +//! agent runs over stdio;
7 +//! - an ACP client on the other end, which forwards `session/update` notifications and
8 +//! `session/request_permission` requests to the webview, serves `fs/read_text_file` and
9 +//! `fs/write_text_file` inside the session's roots, and opens the page of a URL-mode
10 +//! elicitation in the system browser.
11 +
12 +use std::collections::HashMap;
13 +use std::path::{Path, PathBuf};
14 +use std::sync::atomic::{AtomicBool, Ordering};
15 +use std::sync::{Arc, Mutex};
16 +
17 +use agent_client_protocol::schema::v1::{
18 + AuthMethodId, AuthenticateRequest, CancelNotification, ClientCapabilities,
19 + CompleteElicitationNotification, ContentBlock, CreateElicitationRequest,
20 + CreateElicitationResponse, ElicitationAcceptAction, ElicitationAction, ElicitationCapabilities,
21 + ElicitationMode, ElicitationUrlCapabilities, ErrorCode, FileSystemCapabilities, Implementation,
22 + InitializeRequest, ListSessionsRequest, LoadSessionRequest, LogoutRequest, NewSessionRequest,
23 + PromptRequest, ReadTextFileRequest, ReadTextFileResponse, RequestPermissionOutcome,
24 + RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
25 + SessionConfigOption, SessionConfigOptionValue, SessionId, SessionNotification,
26 + SetSessionConfigOptionRequest, TextContent, WriteTextFileRequest, WriteTextFileResponse,
27 +};
28 +use agent_client_protocol::schema::ProtocolVersion;
29 +use agent_client_protocol::{Agent, Channel, Client, ConnectionTo};
30 +use ed_acp::llm::LlmClient;
31 +use ed_acp::{cli, LlmConfig, ServeOptions};
32 +use log::{error, info, warn};
33 +use serde_json::json;
34 +use tauri::{AppHandle, Emitter};
35 +use tauri_plugin_opener::OpenerExt;
36 +use tokio::sync::oneshot;
37 +
38 +use crate::{
39 + AgentSessionOpened, AgentSessionSummary, AgentStatus, AgentTurnEndedPayload, HostConfig, Roots,
40 + AUTH_REQUIRED,
41 +};
42 +
43 +type Pending = Arc<Mutex<HashMap<String, oneshot::Sender<RequestPermissionOutcome>>>>;
44 +/// The roots each open session may use, by session id.
45 +type SessionRoots = Arc<Mutex<HashMap<String, Roots>>>;
46 +
47 +/// Managed Tauri state: the live connection and the permission requests waiting on the user.
48 +pub struct AgentState {
49 + config: HostConfig,
50 + connection: tokio::sync::Mutex<Option<Live>>,
51 + permissions: Pending,
52 + roots: SessionRoots,
53 +}
54 +
55 +struct Live {
56 + conn: ConnectionTo<Agent>,
57 + /// The ids of the auth methods the agent advertised in `initialize`.
58 + auth_methods: Vec<String>,
59 + /// Dropped (or fired) to hang up, which stops the agent too.
60 + _hang_up: oneshot::Sender<()>,
61 + /// Set when the client side has ended, so the next call reconnects.
62 + ended: Arc<AtomicBool>,
63 +}
64 +
65 +/// The `cwd` to ask `session/list` for, or `None` for every stored session.
66 +///
67 +/// On a desktop the agent's data directory can be shared with the command-line agent, so the
68 +/// app asks only for the conversations in the folder it opened. On iOS that directory is
69 +/// inside the app's container, which nothing else writes to and whose path changes with every
70 +/// app update. Sessions stored under the old path would never match, and the user's earlier
71 +/// conversations would drop out of the list. Loading one rebinds it to the current path.
72 +fn list_filter(cwd: &Path, private_data_dir: bool) -> Option<PathBuf> {
73 + (!private_data_dir).then(|| cwd.to_path_buf())
74 +}
75 +
76 +fn list_request(roots: &Roots) -> ListSessionsRequest {
77 + ListSessionsRequest::new().cwd(list_filter(&roots.cwd, cfg!(target_os = "ios")))
78 +}
79 +
80 +/// What the agent will use, without starting it: the profile's own session when it has an
81 +/// auth provider, else the environment and the stored `env` file.
82 +pub fn status(host: &HostConfig) -> AgentStatus {
83 + let profile = (host.profile)();
84 + let config = match profile.auth() {
85 + Some(auth) => auth.llm_config(),
86 + None => Some(LlmConfig::from_env(&profile.info().env)),
87 + };
88 + match config {
89 + Some(config) => AgentStatus {
90 + available: true,
91 + has_key: config.api_key.is_some(),
92 + model: config.model,
93 + },
94 + None => AgentStatus {
95 + available: true,
96 + has_key: false,
97 + model: profile.info().env.default_model.to_string(),
98 + },
99 + }
100 +}
101 +
102 +/// What `<agent> --setup` does for Onde Inference, minus the terminal: check the key with Onde
103 +/// Cloud, then write it to the agent's `env` file (mode 0600). With a `model` it is written as
104 +/// `<PREFIX>_MODEL` beside the key and used for the check, so the agent doesn't fall back to a
105 +/// default tier the key isn't allowed to call. In the App Store sandbox that file lives in
106 +/// the app's container, so a key stored by the command-line agent isn't shared.
107 +pub async fn set_api_key(host: &HostConfig, key: &str, model: Option<&str>) -> Result<(), String> {
108 + let info = host.info();
109 + let key = key.trim();
110 + if !cli::valid_onde_key(key) {
111 + return Err("An Onde key looks like \"app-id:app-secret\".".to_string());
112 + }
113 + let provider_var = info.env.var("PROVIDER");
114 + let mut vars = vec![
115 + (provider_var, "onde".to_string()),
116 + ("ONDE_API_KEY".to_string(), key.to_string()),
117 + ];
118 + if let Some(model) = model {
119 + vars.push((info.env.var("MODEL"), model.to_string()));
120 + }
121 + let config = LlmConfig::from_lookup(&info.env, |name| {
122 + vars.iter()
123 + .find(|(k, _)| k == name)
124 + .map(|(_, v)| v.clone())
125 + .or_else(|| std::env::var(name).ok())
126 + });
127 + LlmClient::new(config)
128 + .check_auth()
129 + .await
130 + .map_err(|e| format!("Onde Cloud rejected the key: {e:#}"))?;
131 +
132 + let dir = info
133 + .env
134 + .config_dir()
135 + .ok_or("No config directory available.")?;
136 + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
137 + let path = dir.join("env");
138 + let existing = std::fs::read_to_string(&path).unwrap_or_default();
139 + std::fs::write(&path, cli::with_vars(&existing, &vars))
140 + .map_err(|e| format!("{}: {e}", path.display()))?;
141 + #[cfg(unix)]
142 + {
143 + use std::os::unix::fs::PermissionsExt;
144 + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
145 + .map_err(|e| format!("{}: {e}", path.display()))?;
146 + }
147 + info!("Stored the agent's Onde key in {}", path.display());
148 + Ok(())
149 +}
150 +
151 +/// Remove the agent's Onde key, and the model that came with it, from its env file. Nothing to
152 +/// do when there is no file or no key in it.
153 +pub fn clear_api_key(host: &HostConfig) -> Result<(), String> {
154 + let info = host.info();
155 + let Some(dir) = info.env.config_dir() else {
156 + return Ok(());
157 + };
158 + let path = dir.join("env");
159 + let existing = match std::fs::read_to_string(&path) {
160 + Ok(existing) => existing,
161 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
162 + Err(e) => return Err(format!("{}: {e}", path.display())),
163 + };
164 + let (kept, removed) = without_api_key(&existing, &info.env.var("MODEL"));
165 + if removed {
166 + std::fs::write(&path, kept).map_err(|e| format!("{}: {e}", path.display()))?;
167 + info!("Removed the agent's Onde key from {}", path.display());
168 + }
169 + Ok(())
170 +}
171 +
172 +/// `env` file content without any `ONDE_API_KEY` or `model_var` line, and whether a key was
173 +/// there.
174 +fn without_api_key(existing: &str, model_var: &str) -> (String, bool) {
175 + let model_prefix = format!("{model_var}=");
176 + let mut removed = false;
177 + let kept: Vec<&str> = existing
178 + .lines()
179 + .filter(|line| {
180 + let line = line.trim();
181 + let line = line.strip_prefix("export ").unwrap_or(line);
182 + let is_key = line.starts_with("ONDE_API_KEY=");
183 + removed |= is_key;
184 + !is_key && !line.starts_with(&model_prefix)
185 + })
186 + .collect();
187 + let mut out = kept.join("\n");
188 + if !out.is_empty() {
189 + out.push('\n');
190 + }
191 + (out, removed)
192 +}
193 +
194 +impl AgentState {
195 + pub fn new(config: HostConfig) -> Self {
196 + Self {
197 + config,
198 + connection: tokio::sync::Mutex::new(None),
199 + permissions: Pending::default(),
200 + roots: SessionRoots::default(),
201 + }
202 + }
203 +
204 + /// The live connection, starting the agent and the client on first use or after the
205 + /// previous connection ended.
206 + async fn connection(
207 + &self,
208 + app: &AppHandle,
209 + ) -> Result<(ConnectionTo<Agent>, Vec<String>), String> {
210 + let mut slot = self.connection.lock().await;
211 + if let Some(live) = slot.as_ref() {
212 + if !live.ended.load(Ordering::SeqCst) {
213 + return Ok((live.conn.clone(), live.auth_methods.clone()));
214 + }
215 + }
216 + let live = self.connect(app).await?;
217 + let out = (live.conn.clone(), live.auth_methods.clone());
218 + *slot = Some(live);
219 + Ok(out)
220 + }
221 +
222 + async fn connect(&self, app: &AppHandle) -> Result<Live, String> {
223 + let (agent_end, client_end) = Channel::duplex();
224 +
225 + let opts = ServeOptions {
226 + yolo: false,
227 + surface: self.config.surface,
228 + };
229 + let profile = (self.config.profile)();
230 + let agent_name = profile.info().display_name;
231 + tauri::async_runtime::spawn(async move {
232 + if let Err(e) = ed_acp::serve(profile, opts, agent_end).await {
233 + error!("{agent_name} agent stopped: {e}");
234 + }
235 + });
236 +
237 + let (ready_tx, ready_rx) =
238 + oneshot::channel::<Result<(ConnectionTo<Agent>, Vec<String>), String>>();
239 + let (hang_up, hung_up) = oneshot::channel::<()>();
240 + let ended = Arc::new(AtomicBool::new(false));
241 +
242 + let client = Client
243 + .builder()
244 + .name(self.config.client_name)
245 + .on_receive_notification(
246 + {
247 + let app = app.clone();
248 + let event = self.config.events.session_update;
249 + async move |n: SessionNotification, _cx| {
250 + if let Err(e) = app.emit(event, &n) {
251 + error!("Failed to emit agent session update: {e:?}");
252 + }
253 + Ok(())
254 + }
255 + },
256 + agent_client_protocol::on_receive_notification!(),
257 + )
258 + .on_receive_notification(
259 + async move |n: CompleteElicitationNotification, _cx| {
260 + info!("Sign-in page completed ({})", n.elicitation_id);
261 + Ok(())
262 + },
263 + agent_client_protocol::on_receive_notification!(),
264 + )
265 + .on_receive_request(
266 + {
267 + let app = app.clone();
268 + async move |req: CreateElicitationRequest, responder, _cx| {
269 + responder.respond(CreateElicitationResponse::new(open_url(&app, &req)))
270 + }
271 + },
272 + agent_client_protocol::on_receive_request!(),
273 + )
274 + .on_receive_request(
275 + {
276 + let app = app.clone();
277 + let pending = self.permissions.clone();
278 + let event = self.config.events.permission_requested;
279 + async move |req: RequestPermissionRequest, responder, cx| {
280 + // Wait for the user off the dispatch loop so updates keep flowing.
281 + let request_id = uuid::Uuid::new_v4().to_string();
282 + let (answer_tx, answer_rx) = oneshot::channel();
283 + pending
284 + .lock()
285 + .unwrap()
286 + .insert(request_id.clone(), answer_tx);
287 + let payload = json!({ "requestId": request_id, "request": req });
288 + if let Err(e) = app.emit(event, &payload) {
289 + error!("Failed to emit agent permission request: {e:?}");
290 + pending.lock().unwrap().remove(&request_id);
291 + }
292 + cx.spawn(async move {
293 + let outcome = answer_rx
294 + .await
295 + .unwrap_or(RequestPermissionOutcome::Cancelled);
296 + responder.respond(RequestPermissionResponse::new(outcome))
297 + })
298 + }
299 + },
300 + agent_client_protocol::on_receive_request!(),
301 + )
302 + .on_receive_request(
303 + {
304 + let roots = self.roots.clone();
305 + async move |req: ReadTextFileRequest, responder, _cx| {
306 + let roots = roots.clone();
307 + let result =
308 + tokio::task::spawn_blocking(move || read_text_file(&roots, &req)).await;
309 + match result {
310 + Ok(Ok(r)) => responder.respond(r),
311 + Ok(Err(e)) => responder.respond_with_error(e),
312 + Err(e) => responder.respond_with_internal_error(e.to_string()),
313 + }
314 + }
315 + },
316 + agent_client_protocol::on_receive_request!(),
317 + )
318 + .on_receive_request(
319 + {
320 + let roots = self.roots.clone();
321 + async move |req: WriteTextFileRequest, responder, _cx| {
322 + let roots = roots.clone();
323 + let result =
324 + tokio::task::spawn_blocking(move || write_text_file(&roots, &req))
325 + .await;
326 + match result {
327 + Ok(Ok(r)) => responder.respond(r),
328 + Ok(Err(e)) => responder.respond_with_error(e),
329 + Err(e) => responder.respond_with_internal_error(e.to_string()),
330 + }
331 + }
332 + },
333 + agent_client_protocol::on_receive_request!(),
334 + );
335 +
336 + let ended_flag = ended.clone();
337 + let pending = self.permissions.clone();
338 + let client_name = self.config.client_name;
339 + let client_version = self.config.client_version;
340 + tauri::async_runtime::spawn(async move {
341 + let result = client
342 + .connect_with(client_end, |conn: ConnectionTo<Agent>| async move {
343 + let init = conn
344 + .send_request(
345 + InitializeRequest::new(ProtocolVersion::V1)
346 + .client_capabilities(
347 + ClientCapabilities::new()
348 + .fs(FileSystemCapabilities::new()
349 + .read_text_file(true)
350 + .write_text_file(true))
351 + .elicitation(
352 + ElicitationCapabilities::new()
353 + .url(ElicitationUrlCapabilities::new()),
354 + ),
355 + )
356 + .client_info(Implementation::new(client_name, client_version)),
357 + )
358 + .block_task()
359 + .await;
360 + match init {
361 + Ok(init) => {
362 + let methods = init
363 + .auth_methods
364 + .iter()
365 + .map(|m| m.id().to_string())
366 + .collect();
367 + let _ = ready_tx.send(Ok((conn, methods)));
368 + // Stay connected until the state is dropped or replaced.
369 + let _ = hung_up.await;
370 + Ok(())
371 + }
372 + Err(e) => {
373 + let _ = ready_tx.send(Err(format!("Agent failed to start: {e}")));
374 + Err(e)
375 + }
376 + }
377 + })
378 + .await;
379 + if let Err(e) = result {
380 + warn!("Agent connection ended: {e}");
381 + }
382 + ended_flag.store(true, Ordering::SeqCst);
383 + // Nobody can answer these any more.
384 + pending.lock().unwrap().clear();
385 + });
386 +
387 + let (conn, auth_methods) = ready_rx
388 + .await
389 + .map_err(|_| "Agent failed to start.".to_string())??;
390 + info!("Agent connected in-process");
391 + Ok(Live {
392 + conn,
393 + auth_methods,
394 + _hang_up: hang_up,
395 + ended,
396 + })
397 + }
398 +
399 + fn roots_for(&self, app: &AppHandle, cwd: Option<&Path>) -> Result<Roots, String> {
400 + (self.config.roots)(app, cwd)
401 + }
402 +
403 + /// The agent's conversations in `cwd` (the host's default roots when `None`), newest
404 + /// first.
405 + pub async fn list_sessions(
406 + &self,
407 + app: &AppHandle,
408 + cwd: Option<PathBuf>,
409 + ) -> Result<Vec<AgentSessionSummary>, String> {
410 + let roots = self.roots_for(app, cwd.as_deref())?;
411 + let (conn, _) = self.connection(app).await?;
412 + let listed = conn
413 + .send_request(list_request(&roots))
414 + .block_task()
415 + .await
416 + .map_err(describe)?;
417 + Ok(listed
418 + .sessions
419 + .into_iter()
420 + .map(|session| AgentSessionSummary {
421 + session_id: session.session_id.to_string(),
422 + title: session.title,
423 + updated_at: session.updated_at,
424 + })
425 + .collect())
426 + }
427 +
428 + /// Open a conversation in `cwd`: the one named by `session_id`, else (unless `fresh`) the
429 + /// latest in that folder, else a new one.
430 + pub async fn open_session(
431 + &self,
432 + app: &AppHandle,
433 + cwd: Option<PathBuf>,
434 + fresh: bool,
435 + access_token: Option<String>,
436 + session_id: Option<String>,
437 + ) -> Result<AgentSessionOpened, String> {
438 + let roots = self.roots_for(app, cwd.as_deref())?;
439 + let (conn, _) = self.connection(app).await?;
440 + let mcp_servers = (self.config.mcp_servers)(app, access_token);
441 + // The one the user picked, else the latest.
442 + let picked = session_id.is_some();
443 + let target = match session_id {
444 + Some(id) => Some(SessionId::new(id)),
445 + None if !fresh => conn
446 + .send_request(list_request(&roots))
447 + .block_task()
448 + .await
449 + .map_err(describe)?
450 + .sessions
451 + .into_iter()
452 + // Newest first.
453 + .next()
454 + .map(|latest| latest.session_id),
455 + None => None,
456 + };
457 + if let Some(id) = target {
458 + // The agent may read files while it replays, so the roots come first.
459 + self.roots
460 + .lock()
461 + .unwrap()
462 + .insert(id.to_string(), roots.clone());
463 + let load = LoadSessionRequest::new(id.clone(), roots.cwd.clone())
464 + .additional_directories(roots.extra.clone())
465 + .mcp_servers(mcp_servers.clone());
466 + match conn.send_request(load).block_task().await {
467 + Ok(loaded) => {
468 + return Ok(AgentSessionOpened {
469 + session_id: id.to_string(),
470 + restored: true,
471 + config_options: option_values(loaded.config_options),
472 + });
473 + }
474 + Err(e) if e.code == ErrorCode::AuthRequired => {
475 + self.roots.lock().unwrap().remove(&id.to_string());
476 + return Err(AUTH_REQUIRED.to_string());
477 + }
478 + Err(e) => {
479 + self.roots.lock().unwrap().remove(&id.to_string());
480 + // One the user picked and can't have is an error to show.
481 + if picked {
482 + return Err(describe(e));
483 + }
484 + // The latest no longer loading shouldn't block a new one.
485 + warn!("Could not load agent session {id}: {e}");
486 + }
487 + }
488 + }
489 + let new = NewSessionRequest::new(roots.cwd.clone())
490 + .additional_directories(roots.extra.clone())
491 + .mcp_servers(mcp_servers);
492 + let session = conn
493 + .send_request(new)
494 + .block_task()
495 + .await
496 + .map_err(describe)?;
497 + self.roots
498 + .lock()
499 + .unwrap()
500 + .insert(session.session_id.to_string(), roots);
501 + Ok(AgentSessionOpened {
502 + session_id: session.session_id.to_string(),
503 + restored: false,
504 + config_options: option_values(session.config_options),
505 + })
506 + }
507 +
508 + pub async fn prompt(
509 + &self,
510 + app: &AppHandle,
511 + session_id: String,
512 + text: String,
513 + ) -> Result<(), String> {
514 + let (conn, _) = self.connection(app).await?;
515 + let request = PromptRequest::new(
516 + SessionId::new(session_id.clone()),
517 + vec![ContentBlock::Text(TextContent::new(text))],
518 + );
519 + let app = app.clone();
520 + let event = self.config.events.turn_ended;
521 + // Returned before the turn ends: a long turn would otherwise hold the invoke
522 + // callback open for minutes.
523 + tauri::async_runtime::spawn(async move {
524 + let result = conn.send_request(request).block_task().await;
525 + let payload = match result {
526 + Ok(r) => AgentTurnEndedPayload {
527 + session_id,
528 + stop_reason: serde_json::to_value(r.stop_reason)
529 + .ok()
530 + .and_then(|v| v.as_str().map(String::from)),
531 + error: None,
532 + },
533 + Err(e) => AgentTurnEndedPayload {
534 + session_id,
535 + stop_reason: None,
536 + error: Some(describe(e)),
537 + },
538 + };
539 + if let Err(e) = app.emit(event, &payload) {
540 + error!("Failed to emit agent turn end: {e:?}");
541 + }
542 + });
543 + Ok(())
544 + }
545 +
546 + pub async fn cancel(&self, session_id: String) -> Result<(), String> {
547 + // ACP: a client cancelling a turn answers its open permission requests with
548 + // `cancelled`.
549 + for (_, answer) in self.permissions.lock().unwrap().drain() {
550 + let _ = answer.send(RequestPermissionOutcome::Cancelled);
551 + }
552 + let slot = self.connection.lock().await;
553 + let Some(live) = slot.as_ref() else {
554 + return Ok(());
555 + };
556 + live.conn
557 + .send_notification(CancelNotification::new(SessionId::new(session_id)))
558 + .map_err(|e| e.to_string())
559 + }
560 +
561 + pub fn respond_permission(
562 + &self,
563 + request_id: &str,
564 + option_id: Option<String>,
565 + ) -> Result<(), String> {
566 + let answer = self
567 + .permissions
568 + .lock()
569 + .unwrap()
570 + .remove(request_id)
571 + .ok_or("That request is no longer waiting for an answer.")?;
572 + let outcome = match option_id {
573 + Some(id) => RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)),
574 + None => RequestPermissionOutcome::Cancelled,
575 + };
576 + let _ = answer.send(outcome);
577 + Ok(())
578 + }
579 +
580 + /// Change a config option (the model, auto-approve). `value` is a select value id as a
581 + /// string, or a boolean. Returns the session's options as they are afterwards.
582 + pub async fn set_config_option(
583 + &self,
584 + app: &AppHandle,
585 + session_id: String,
586 + config_id: String,
587 + value: serde_json::Value,
588 + ) -> Result<Vec<serde_json::Value>, String> {
589 + let value = match value {
590 + serde_json::Value::Bool(value) => SessionConfigOptionValue::from(value),
591 + serde_json::Value::String(value) => SessionConfigOptionValue::from(value.as_str()),
592 + other => return Err(format!("Not a config option value: {other}")),
593 + };
594 + let (conn, _) = self.connection(app).await?;
595 + let response = conn
596 + .send_request(SetSessionConfigOptionRequest::new(
597 + SessionId::new(session_id),
598 + config_id,
599 + value,
600 + ))
601 + .block_task()
602 + .await
603 + .map_err(describe)?;
604 + Ok(option_values(Some(response.config_options)))
605 + }
606 +
607 + /// Sign in with one of the agent's auth methods: `method_id`, else the first it
608 + /// advertised. For a product with its own sign-in this opens the browser through the
609 + /// client's URL elicitation and returns once the page is finished.
610 + pub async fn authenticate(
611 + &self,
612 + app: &AppHandle,
613 + method_id: Option<String>,
614 + ) -> Result<(), String> {
615 + let (conn, methods) = self.connection(app).await?;
616 + let method = method_id
617 + .or_else(|| methods.first().cloned())
618 + .ok_or("The agent offers no way to sign in.")?;
619 + conn.send_request(AuthenticateRequest::new(AuthMethodId::new(method)))
620 + .block_task()
621 + .await
622 + .map_err(|e| e.to_string())?;
623 + Ok(())
624 + }
625 +
626 + /// Sign out: the agent forgets its stored session.
627 + pub async fn logout(&self, app: &AppHandle) -> Result<(), String> {
628 + let (conn, _) = self.connection(app).await?;
629 + conn.send_request(LogoutRequest::new())
630 + .block_task()
631 + .await
632 + .map_err(|e| e.to_string())?;
633 + Ok(())
634 + }
635 +}
636 +
637 +/// Answer a URL-mode elicitation by opening its page in the system browser. Only `http` and
638 +/// `https` are opened; the user "declines" anything else, or a browser that won't launch.
639 +fn open_url(app: &AppHandle, req: &CreateElicitationRequest) -> ElicitationAction {
640 + let ElicitationMode::Url(mode) = &req.mode else {
641 + // Form mode was never advertised.
642 + return ElicitationAction::Decline;
643 + };
644 + if !(mode.url.starts_with("https://") || mode.url.starts_with("http://")) {
645 + warn!("Refusing to open a non-web sign-in URL");
646 + return ElicitationAction::Decline;
647 + }
648 + match app.opener().open_url(&mode.url, None::<&str>) {
649 + Ok(()) => ElicitationAction::Accept(ElicitationAcceptAction::new()),
650 + Err(e) => {
651 + warn!("Could not open the sign-in page: {e}");
652 + ElicitationAction::Decline
653 + }
654 + }
655 +}
656 +
657 +fn option_values(options: Option<Vec<SessionConfigOption>>) -> Vec<serde_json::Value> {
658 + options
659 + .unwrap_or_default()
660 + .into_iter()
661 + .filter_map(|option| serde_json::to_value(option).ok())
662 + .collect()
663 +}
664 +
665 +/// [`AUTH_REQUIRED`] for a missing key or session, so the page can ask for sign-in; the
666 +/// agent's message otherwise.
667 +fn describe(e: agent_client_protocol::Error) -> String {
668 + if e.code == ErrorCode::AuthRequired {
669 + AUTH_REQUIRED.to_string()
670 + } else {
671 + e.to_string()
672 + }
673 +}
674 +
675 +fn outside(path: &Path) -> agent_client_protocol::Error {
676 + agent_client_protocol::Error::invalid_params().data(format!(
677 + "{} is outside the session's workspace",
678 + path.display()
679 + ))
680 +}
681 +
682 +fn roots_of(
683 + roots: &SessionRoots,
684 + session: &SessionId,
685 + path: &Path,
686 +) -> agent_client_protocol::Result<Roots> {
687 + roots
688 + .lock()
689 + .unwrap()
690 + .get(&session.to_string())
691 + .cloned()
692 + .filter(|roots| roots.contains(path))
693 + .ok_or_else(|| outside(path))
694 +}
695 +
696 +/// `fs/read_text_file`, honouring the optional 1-based `line` and `limit`.
697 +fn read_text_file(
698 + roots: &SessionRoots,
699 + req: &ReadTextFileRequest,
700 +) -> agent_client_protocol::Result<ReadTextFileResponse> {
701 + roots_of(roots, &req.session_id, &req.path)?;
702 + let content = std::fs::read_to_string(&req.path).map_err(|e| {
703 + if e.kind() == std::io::ErrorKind::NotFound {
704 + agent_client_protocol::Error::resource_not_found(Some(req.path.display().to_string()))
705 + } else {
706 + agent_client_protocol::Error::internal_error().data(e.to_string())
707 + }
708 + })?;
709 + let skip = req.line.map_or(0, |l| l.saturating_sub(1) as usize);
710 + let take = req.limit.map_or(usize::MAX, |l| l as usize);
711 + let content = content
712 + .lines()
713 + .skip(skip)
714 + .take(take)
715 + .collect::<Vec<_>>()
716 + .join("\n");
717 + Ok(ReadTextFileResponse::new(content))
718 +}
719 +
720 +/// `fs/write_text_file`: the client creates the file and its parent directories when they
721 +/// don't exist.
722 +fn write_text_file(
723 + roots: &SessionRoots,
724 + req: &WriteTextFileRequest,
725 +) -> agent_client_protocol::Result<WriteTextFileResponse> {
726 + roots_of(roots, &req.session_id, &req.path)?;
727 + let write = || {
728 + if let Some(parent) = req.path.parent() {
729 + std::fs::create_dir_all(parent)?;
730 + }
731 + std::fs::write(&req.path, &req.content)
732 + };
733 + write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
734 + Ok(WriteTextFileResponse::new())
735 +}
736 +
737 +#[cfg(test)]
738 +mod tests {
739 + use super::*;
740 +
741 + #[test]
742 + fn sessions_are_listed_by_directory_unless_the_data_dir_is_private() {
743 + let cwd = Path::new("/data/agent");
744 + assert_eq!(list_filter(cwd, false), Some(cwd.to_path_buf()));
745 + // iOS: the container path changes with every update, so no filter.
746 + assert_eq!(list_filter(cwd, true), None);
747 + }
748 +
749 + #[test]
750 + fn removing_the_key_keeps_every_other_line() {
751 + let (kept, removed) = without_api_key(
752 + "AGENT_PROVIDER=onde\nONDE_API_KEY=app:secret\nAGENT_MODEL=x\nAGENT_SURFACE=app\n",
753 + "AGENT_MODEL",
754 + );
755 + assert!(removed);
756 + assert_eq!(kept, "AGENT_PROVIDER=onde\nAGENT_SURFACE=app\n");
757 + }
758 +
759 + #[test]
760 + fn an_exported_key_goes_too_and_a_file_without_one_is_untouched() {
761 + assert_eq!(
762 + without_api_key("export ONDE_API_KEY=a:b\n", "AGENT_MODEL"),
763 + (String::new(), true)
764 + );
765 + assert_eq!(
766 + without_api_key("AGENT_PROVIDER=onde\n", "AGENT_MODEL"),
767 + ("AGENT_PROVIDER=onde\n".to_string(), false)
768 + );
769 + assert_eq!(without_api_key("", "AGENT_MODEL"), (String::new(), false));
770 + }
771 +
772 + #[test]
773 + fn a_similar_variable_is_not_the_key() {
774 + let (kept, removed) =
775 + without_api_key("ONDE_API_KEY_BACKUP=x\nAGENT_MODEL_X=y\n", "AGENT_MODEL");
776 + assert!(!removed);
777 + assert_eq!(kept, "ONDE_API_KEY_BACKUP=x\nAGENT_MODEL_X=y\n");
778 + }
779 +
780 + #[test]
781 + fn a_session_reaches_only_its_own_roots() {
782 + let roots = SessionRoots::default();
783 + roots.lock().unwrap().insert(
784 + "a".into(),
785 + Roots {
786 + cwd: PathBuf::from("/work/a"),
787 + extra: Vec::new(),
788 + },
789 + );
790 + let a = SessionId::new("a");
791 + let b = SessionId::new("b");
792 + assert!(roots_of(&roots, &a, Path::new("/work/a/src/main.rs")).is_ok());
793 + assert!(roots_of(&roots, &a, Path::new("/work/b/src/main.rs")).is_err());
794 + // An id nobody opened has no roots at all.
795 + assert!(roots_of(&roots, &b, Path::new("/work/a/src/main.rs")).is_err());
796 + }
797 +}
crates/ed-acp-tauri/src/lib.rs new
+193
@@ -0,0 +1,193 @@
1 +//! An [`ed_acp`] agent, hosted inside a Tauri app.
2 +//!
3 +//! A sandboxed App Store build can't launch an ACP agent as a subprocess the way an editor
4 +//! does, so the app runs it in-process: [`AgentState`] serves the agent's [`Profile`] with
5 +//! `ed_acp::serve` over an in-memory channel and talks to it as a regular ACP client. Nothing
6 +//! about the protocol changes; only the transport does.
7 +//!
8 +//! The frontend sees ACP as Tauri commands and events. Session updates are forwarded
9 +//! untouched, so the page renders the ACP schema directly. This crate holds everything that
10 +//! doesn't depend on the app; each app passes a [`HostConfig`] and wraps [`AgentState`]'s
11 +//! methods in its own `#[tauri::command]`s.
12 +//!
13 +//! The client advertises `fs.readTextFile`, `fs.writeTextFile` and `elicitation.url`. Reads
14 +//! and writes are served inside the session's [`Roots`]. A URL elicitation (a product's own
15 +//! sign-in page) is opened in the system browser through `tauri-plugin-opener`, which the app
16 +//! has to register. The client advertises no `terminal`, so the agent runs commands itself,
17 +//! after asking for permission.
18 +
19 +mod client;
20 +
21 +use std::path::{Path, PathBuf};
22 +use std::sync::Arc;
23 +
24 +use ed_acp::agent_client_protocol::schema::v1::McpServer;
25 +use ed_acp::{normalize, AgentInfo, Profile};
26 +use serde::{Deserialize, Serialize};
27 +use tauri::{AppHandle, Manager};
28 +use tsync::tsync;
29 +
30 +pub use client::{clear_api_key, set_api_key, status, AgentState};
31 +
32 +/// Returned by commands when the agent isn't signed in or has no API key. The page offers
33 +/// sign-in when it sees this.
34 +pub const AUTH_REQUIRED: &str = "auth_required";
35 +
36 +/// Names of the Tauri events the host emits to the webview.
37 +#[derive(Clone, Copy)]
38 +pub struct Events {
39 + /// ACP `session/update` notifications, forwarded untouched.
40 + pub session_update: &'static str,
41 + /// `{ requestId, request }` for a permission prompt.
42 + pub permission_requested: &'static str,
43 + /// An [`AgentTurnEndedPayload`].
44 + pub turn_ended: &'static str,
45 +}
46 +
47 +/// The directories a session may use: its working directory and extra roots.
48 +#[derive(Clone, Debug, PartialEq, Eq)]
49 +pub struct Roots {
50 + pub cwd: PathBuf,
51 + pub extra: Vec<PathBuf>,
52 +}
53 +
54 +impl Roots {
55 + /// The app's own scratch directory (`<app data>/agent`), created if missing, plus `extra`.
56 + /// What a host that has no project folder of its own wants as `cwd`.
57 + pub fn app_scratch(app: &AppHandle, extra: Vec<PathBuf>) -> Result<Self, String> {
58 + let cwd = app
59 + .path()
60 + .app_data_dir()
61 + .map_err(|e| format!("No app data directory: {e}"))?
62 + .join("agent");
63 + std::fs::create_dir_all(&cwd).map_err(|e| format!("{}: {e}", cwd.display()))?;
64 + Ok(Self { cwd, extra })
65 + }
66 +
67 + /// A project folder as the working directory. It must be absolute and an existing
68 + /// directory: it becomes the boundary the agent's file tools stay inside.
69 + pub fn project(cwd: &Path) -> Result<Self, String> {
70 + if !cwd.is_absolute() {
71 + return Err(format!("{} is not an absolute path.", cwd.display()));
72 + }
73 + if !cwd.is_dir() {
74 + return Err(format!("{} is not a directory.", cwd.display()));
75 + }
76 + Ok(Self {
77 + cwd: cwd.to_path_buf(),
78 + extra: Vec::new(),
79 + })
80 + }
81 +
82 + /// Whether `path` is absolute and inside one of the roots, after removing `.` and `..`,
83 + /// so the agent can't walk out of them.
84 + pub fn contains(&self, path: &Path) -> bool {
85 + if !path.is_absolute() {
86 + return false;
87 + }
88 + let path = normalize(path);
89 + std::iter::once(&self.cwd)
90 + .chain(&self.extra)
91 + .any(|root| path.starts_with(normalize(root)))
92 + }
93 +}
94 +
95 +/// Everything about the app that the host can't know.
96 +#[derive(Clone)]
97 +pub struct HostConfig {
98 + /// Reported to the agent as `clientInfo.name`.
99 + pub client_name: &'static str,
100 + /// `clientInfo.version`.
101 + pub client_version: &'static str,
102 + /// `<PREFIX>_SURFACE` for the agent's prompt: `app`, neither an editor nor its TUI.
103 + pub surface: &'static str,
104 + /// The agent's profile, without what the platform can't run.
105 + pub profile: fn() -> Arc<dyn Profile>,
106 + /// The roots for a session. The argument is the folder the frontend asked for, if any;
107 + /// `Roots::app_scratch` is the answer for an app without project folders.
108 + pub roots: fn(&AppHandle, Option<&Path>) -> Result<Roots, String>,
109 + /// The MCP servers for a new session, given the signed-in user's access token when the app
110 + /// has one its agent should present. A profile's own built-in servers need none.
111 + pub mcp_servers: fn(&AppHandle, Option<String>) -> Vec<McpServer>,
112 + pub events: Events,
113 +}
114 +
115 +impl HostConfig {
116 + /// The profile's identity: where its env file lives and which variables it reads.
117 + pub fn info(&self) -> AgentInfo {
118 + (self.profile)().info()
119 + }
120 +}
121 +
122 +/// Response returned by `agent_status`.
123 +#[tsync]
124 +#[derive(Debug, Clone, Serialize, Deserialize)]
125 +pub struct AgentStatus {
126 + /// `false` on platforms the agent isn't built for.
127 + pub available: bool,
128 + /// Whether the agent has credentials: a signed-in session or an API key.
129 + pub has_key: bool,
130 + /// The model the agent will use.
131 + pub model: String,
132 +}
133 +
134 +/// One earlier conversation, as `agent_list_sessions` returns it.
135 +#[tsync]
136 +#[derive(Debug, Clone, Serialize, Deserialize)]
137 +pub struct AgentSessionSummary {
138 + pub session_id: String,
139 + /// Set by the agent once the conversation has a first exchange.
140 + pub title: Option<String>,
141 + /// ISO 8601, when the conversation last changed.
142 + pub updated_at: Option<String>,
143 +}
144 +
145 +/// Response returned by `agent_open_session`.
146 +#[tsync]
147 +#[derive(Debug, Clone, Serialize, Deserialize)]
148 +pub struct AgentSessionOpened {
149 + pub session_id: String,
150 + /// `true` when an earlier conversation was loaded. Its history arrives as
151 + /// `agent_session_update` events before this response.
152 + pub restored: bool,
153 + /// The session's ACP `SessionConfigOption`s (model picker, auto-approve), as the agent
154 + /// sent them. `Value` is any JSON.
155 + pub config_options: Vec<serde_json::Value>,
156 +}
157 +
158 +/// Payload emitted with `agent_turn_ended`.
159 +#[tsync]
160 +#[derive(Debug, Clone, Serialize, Deserialize)]
161 +pub struct AgentTurnEndedPayload {
162 + pub session_id: String,
163 + /// ACP stop reason (`end_turn`, `cancelled`, `max_tokens`, …), or `None`
164 + /// on error.
165 + pub stop_reason: Option<String>,
166 + pub error: Option<String>,
167 +}
168 +
169 +#[cfg(test)]
170 +mod tests {
171 + use super::*;
172 +
173 + #[test]
174 + fn roots_keep_the_agent_inside() {
175 + let roots = Roots {
176 + cwd: PathBuf::from("/data/agent"),
177 + extra: vec![PathBuf::from("/data/audio")],
178 + };
179 + assert!(roots.contains(Path::new("/data/agent/notes.md")));
180 + assert!(roots.contains(Path::new("/data/audio/song/bass.wav")));
181 + assert!(!roots.contains(Path::new("/data/agent/../secrets")));
182 + assert!(!roots.contains(Path::new("/data/agentx/file")));
183 + assert!(!roots.contains(Path::new("relative/file")));
184 + }
185 +
186 + #[test]
187 + fn a_project_root_must_be_an_absolute_existing_directory() {
188 + let dir = std::env::temp_dir();
189 + assert_eq!(Roots::project(&dir).unwrap().cwd, dir);
190 + assert!(Roots::project(Path::new("relative")).is_err());
191 + assert!(Roots::project(&dir.join("no-such-folder-ed-acp-tauri")).is_err());
192 + }
193 +}
crates/ed-acp/src/agent.rs
+75 -3
@@ -15,7 +15,7 @@ use agent_client_protocol::schema::v1::{
15 ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse,
16 ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
17 LogoutResponse, McpServer, MessageId, NewSessionRequest, NewSessionResponse, PromptRequest,
18 - PromptResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption,
18 + PromptResponse, RequestId, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption,
19 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId,
20 SessionInfo, SessionInfoUpdate, SessionNotification, SessionUpdate,
21 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
@@ -27,6 +27,7 @@ use tokio_util::sync::CancellationToken;
27
28 use ed_mcp::{ClientInfo, McpToolset, ToolsetConfig};
29
30 +use crate::auth::{AuthCtx, AuthProvider};
31 use crate::content as prompt;
32 use crate::llm::{Delta, LlmClient, LlmConfig, LlmEnv};
33 use crate::store::{SessionStore, StoredSession};
@@ -272,6 +273,8 @@ fn user_text(msg: &Value) -> String {
273 #[derive(Clone)]
274 pub struct AcpServer {
275 profile: Arc<dyn Profile>,
276 + /// The profile's own sign-in, when it has one (see [`Profile::auth`]).
277 + auth: Option<Arc<dyn AuthProvider>>,
278 /// The profile's toolsets, built once.
279 toolsets: Arc<Vec<Arc<dyn Toolset>>>,
280 env: LlmEnv,
@@ -295,11 +298,16 @@ impl AcpServer {
298 pub fn new(profile: Arc<dyn Profile>, yolo: bool, surface: &'static str) -> Self {
299 let env = profile.info().env;
300 let data_dir = env.data_dir();
301 + let auth = profile.auth();
302 Self {
303 toolsets: Arc::new(profile.toolsets()),
304 + llm: Arc::new(Mutex::new(LlmClient::new(Self::config_for(
305 + auth.as_deref(),
306 + &env,
307 + )))),
308 profile,
309 + auth,
310 env,
302 - llm: Arc::new(Mutex::new(LlmClient::new(LlmConfig::from_env(&env)))),
311 yolo,
312 surface,
313 client_caps: Arc::default(),
@@ -310,6 +318,29 @@ impl AcpServer {
318 }
319 }
320
321 + /// The provider's stored session when the profile has one, else the environment. A
322 + /// signed-out provider yields the environment's endpoint with no key, so a stray
323 + /// `ONDE_API_KEY` can't stand in for the product's own sign-in.
324 + fn config_for(auth: Option<&dyn AuthProvider>, env: &LlmEnv) -> LlmConfig {
325 + match auth {
326 + None => LlmConfig::from_env(env),
327 + Some(auth) => auth.llm_config().unwrap_or_else(|| LlmConfig {
328 + api_key: None,
329 + ..LlmConfig::from_env(env)
330 + }),
331 + }
332 + }
333 +
334 + /// The auth methods for `initialize`. A provider's method is offered whatever the client
335 + /// can do; terminal auth only to a client that can run it.
336 + pub fn auth_methods(&self, caps: &ClientCapabilities) -> Vec<AuthMethod> {
337 + match &self.auth {
338 + Some(auth) => vec![auth.method()],
339 + None if supports_terminal_auth(caps) => auth_methods(),
340 + None => Vec::new(),
341 + }
342 + }
343 +
344 pub fn set_client_caps(&self, caps: ClientCapabilities) {
345 *self.client_caps.lock().unwrap() = caps;
346 }
@@ -332,7 +363,7 @@ impl AcpServer {
363 /// `authenticate`, so the stored key has to be picked up here. The model list is only
364 /// dropped when the configuration actually changed.
365 pub async fn refresh_credentials(&self) -> bool {
335 - let fresh = LlmConfig::from_env(&self.env);
366 + let fresh = Self::config_for(self.auth.as_deref(), &self.env);
367 let changed = fresh != *self.llm().config();
368 if changed {
369 tracing::info!(
@@ -349,7 +380,30 @@ impl AcpServer {
380 pub async fn authenticate(
381 &self,
382 req: AuthenticateRequest,
383 + connection: &ConnectionTo<Client>,
384 + request_id: RequestId,
385 ) -> agent_client_protocol::Result<AuthenticateResponse> {
386 + if let Some(auth) = &self.auth {
387 + if req.method_id != *auth.method().id() {
388 + return Err(invalid(format!("unknown auth method {}", req.method_id.0)));
389 + }
390 + let caps = self.client_caps.lock().unwrap().clone();
391 + auth.authenticate(AuthCtx {
392 + connection,
393 + request_id,
394 + client_caps: &caps,
395 + })
396 + .await?;
397 + // A new session means a new bearer, and possibly a different model list.
398 + *self.models.lock().await = None;
399 + *self.llm.lock().unwrap() =
400 + LlmClient::new(Self::config_for(self.auth.as_deref(), &self.env));
401 + return if self.has_api_key() {
402 + Ok(AuthenticateResponse::new())
403 + } else {
404 + Err(self.auth_required())
405 + };
406 + }
407 if &*req.method_id.0 != AUTH_METHOD_ID {
408 return Err(invalid(format!("unknown auth method {}", req.method_id.0)));
409 }
@@ -365,6 +419,18 @@ impl AcpServer {
419 /// the others. A key exported in the agent's own environment can't be removed from here
420 /// and keeps the agent signed in.
421 pub async fn logout(&self) -> agent_client_protocol::Result<LogoutResponse> {
422 + if let Some(auth) = &self.auth {
423 + let result = auth.logout().await;
424 + *self.models.lock().await = None;
425 + *self.llm.lock().unwrap() =
426 + LlmClient::new(Self::config_for(self.auth.as_deref(), &self.env));
427 + return match result {
428 + Ok(()) => Ok(LogoutResponse::new()),
429 + Err(e) => {
430 + Err(agent_client_protocol::Error::internal_error().data(format!("{e:#}")))
431 + }
432 + };
433 + }
434 let failures: Vec<String> = self
435 .env
436 .config_file_candidates()
@@ -396,6 +462,12 @@ impl AcpServer {
462
463 /// `AUTH_REQUIRED`, naming this agent's setup command.
464 pub fn auth_required(&self) -> agent_client_protocol::Error {
465 + if let Some(auth) = &self.auth {
466 + return agent_client_protocol::Error::auth_required().data(format!(
467 + "Not signed in. Authenticate with the {} method.",
468 + auth.method().name()
469 + ));
470 + }
471 auth_required_error(self.profile.info().name)
472 }
473
crates/ed-acp/src/auth.rs new
+51
@@ -0,0 +1,51 @@
1 +//! The auth seam for products with a sign-in of their own.
2 +//!
3 +//! Without a provider the agent only knows terminal auth: a client runs `<agent> --setup`
4 +//! and the key lands in the `env` file. A product whose credentials come from somewhere else
5 +//! (a browser sign-in, a stored session) supplies an [`AuthProvider`] from
6 +//! [`Profile::auth`](crate::Profile::auth) and the agent defers to it.
7 +
8 +use agent_client_protocol::schema::v1::{AuthMethod, ClientCapabilities, RequestId};
9 +use agent_client_protocol::{Client, ConnectionTo};
10 +use async_trait::async_trait;
11 +
12 +use crate::llm::LlmConfig;
13 +
14 +/// What `authenticate` may use to reach the user.
15 +pub struct AuthCtx<'a> {
16 + /// The client connection, for a URL-mode elicitation or a notification.
17 + pub connection: &'a ConnectionTo<Client>,
18 + /// The `authenticate` request's own id. No session exists yet, so it scopes an
19 + /// elicitation sent while the request is open.
20 + pub request_id: RequestId,
21 + /// What the client advertised in `initialize`, e.g. `elicitation.url`.
22 + pub client_caps: &'a ClientCapabilities,
23 +}
24 +
25 +impl AuthCtx<'_> {
26 + /// Whether the client can show a URL-mode elicitation.
27 + pub fn client_offers_url_elicitation(&self) -> bool {
28 + self.client_caps
29 + .elicitation
30 + .as_ref()
31 + .is_some_and(|caps| caps.url.is_some())
32 + }
33 +}
34 +
35 +#[async_trait]
36 +pub trait AuthProvider: Send + Sync {
37 + /// The method advertised in `initialize`, whatever the client's terminal capability,
38 + /// e.g. `AuthMethod::Agent(AuthMethodAgent::new("sigit", "Sign in to siGit Code"))`.
39 + fn method(&self) -> AuthMethod;
40 +
41 + /// The stored session as an endpoint, a bearer and a model, or `None` when signed out.
42 + /// Read before each session request, so a sign-in finished in this process or another
43 + /// is picked up without a restart.
44 + fn llm_config(&self) -> Option<LlmConfig>;
45 +
46 + /// Run the sign-in. An existing session passes. An `Err` is shown to the user as is.
47 + async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()>;
48 +
49 + /// Forget the stored session.
50 + async fn logout(&self) -> anyhow::Result<()>;
51 +}
crates/ed-acp/src/lib.rs
+10
@@ -33,6 +33,7 @@
33 //! ```
34
35 mod agent;
36 +pub mod auth;
37 pub mod cli;
38 pub mod content;
39 pub mod llm;
@@ -51,6 +52,7 @@ use ed_mcp::{Connection, McpTool, McpToolset, SharedServer};
52 use serde_json::Value;
53
54 pub use agent_client_protocol;
55 +pub use auth::{AuthCtx, AuthProvider};
56 pub use content::AudioHints;
57 pub use ed_mcp;
58 pub use llm::{LlmConfig, LlmEnv, Provider};
@@ -143,6 +145,14 @@ pub trait Profile: Send + Sync + 'static {
145 Vec::new()
146 }
147
148 + /// A sign-in of the product's own, for products whose credentials are not an API key in
149 + /// the environment. With one, `initialize` advertises its method, the turn loop reads
150 + /// its [`LlmConfig`], and `authenticate` and `logout` delegate to it. `None` keeps the
151 + /// terminal `--setup` flow.
152 + fn auth(&self) -> Option<Arc<dyn AuthProvider>> {
153 + None
154 + }
155 +
156 /// Advertised in `initialize`. Audio is accepted only when this says so.
157 fn prompt_capabilities(&self) -> PromptCapabilities {
158 PromptCapabilities::new().embedded_context(true).image(true)
crates/ed-acp/src/server.rs
+6 -7
@@ -72,12 +72,7 @@ pub async fn serve(
72 let agent = agent.clone();
73 async move |req: InitializeRequest, responder, _cx| {
74 agent.set_client_caps(req.client_capabilities.clone());
75 - // Terminal auth is only usable by clients that can run it.
76 - let methods = if agent::supports_terminal_auth(&req.client_capabilities) {
77 - agent::auth_methods()
78 - } else {
79 - Vec::new()
80 - };
75 + let methods = agent.auth_methods(&req.client_capabilities);
76 responder.respond(
77 InitializeResponse::new(agent::negotiate_version(req.protocol_version))
78 .auth_methods(methods)
@@ -111,9 +106,13 @@ pub async fn serve(
106 {
107 let agent = agent.clone();
108 async move |req: AuthenticateRequest, responder, cx| {
109 + // Spawned: a provider may wait on the client's answer to an elicitation,
110 + // which the dispatch loop has to be free to deliver.
111 let agent = agent.clone();
112 + let connection = cx.clone();
113 + let request_id = responder.id().clone();
114 cx.spawn(async move {
116 - match agent.authenticate(req).await {
115 + match agent.authenticate(req, &connection, request_id).await {
116 Ok(resp) => responder.respond(resp),
117 Err(e) => responder.respond_with_error(e),
118 }
crates/ed-acp/tests/in_process.rs
+143
@@ -64,3 +64,146 @@ async fn initialize_over_an_in_memory_channel() {
64 .expect("server task")
65 .ok();
66 }
67 +
68 +mod provider_auth {
69 + use std::sync::atomic::{AtomicUsize, Ordering};
70 + use std::sync::{Arc, Mutex};
71 +
72 + use agent_client_protocol::schema::ProtocolVersion;
73 + use agent_client_protocol::schema::v1::{
74 + AuthMethod, AuthMethodAgent, AuthenticateRequest, InitializeRequest, LogoutRequest,
75 + NewSessionRequest,
76 + };
77 + use agent_client_protocol::{Agent, Channel, Client, ConnectionTo};
78 + use async_trait::async_trait;
79 + use ed_acp::{
80 + AgentInfo, AuthCtx, AuthProvider, LlmConfig, LlmEnv, Profile, PromptCtx, Provider,
81 + ServeOptions, Toolset,
82 + };
83 +
84 + /// A provider whose "session" is a string it holds in memory.
85 + #[derive(Default)]
86 + struct State {
87 + token: Mutex<Option<String>>,
88 + authenticated: AtomicUsize,
89 + }
90 +
91 + struct Fake(Arc<State>);
92 +
93 + #[async_trait]
94 + impl AuthProvider for Fake {
95 + fn method(&self) -> AuthMethod {
96 + AuthMethod::Agent(AuthMethodAgent::new("fake", "Sign in to Fake"))
97 + }
98 +
99 + fn llm_config(&self) -> Option<LlmConfig> {
100 + self.0.token.lock().unwrap().clone().map(|token| LlmConfig {
101 + provider: Provider::OpenAi,
102 + // Nothing listens here: listing models fails fast and the default is offered.
103 + base_url: "http://127.0.0.1:1".into(),
104 + api_key: Some(token),
105 + model: "fake-model".into(),
106 + })
107 + }
108 +
109 + async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()> {
110 + assert!(!ctx.client_offers_url_elicitation());
111 + self.0.authenticated.fetch_add(1, Ordering::SeqCst);
112 + *self.0.token.lock().unwrap() = Some("token".into());
113 + Ok(())
114 + }
115 +
116 + async fn logout(&self) -> anyhow::Result<()> {
117 + *self.0.token.lock().unwrap() = None;
118 + Ok(())
119 + }
120 + }
121 +
122 + struct WithAuth(Arc<State>);
123 +
124 + impl Profile for WithAuth {
125 + fn info(&self) -> AgentInfo {
126 + AgentInfo {
127 + name: "provider-agent",
128 + display_name: "Provider",
129 + version: "0.0.1",
130 + env: LlmEnv {
131 + prefix: "ED_ACP_PROVIDER_TEST",
132 + dir_name: "ed-acp-provider-test",
133 + default_model: "fake-model",
134 + },
135 + }
136 + }
137 +
138 + fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String {
139 + String::new()
140 + }
141 +
142 + fn toolsets(&self) -> Vec<Arc<dyn Toolset>> {
143 + Vec::new()
144 + }
145 +
146 + fn auth(&self) -> Option<Arc<dyn AuthProvider>> {
147 + Some(Arc::new(Fake(self.0.clone())))
148 + }
149 + }
150 +
151 + #[tokio::test]
152 + async fn the_provider_owns_initialize_sessions_authenticate_and_logout() {
153 + let fake = Arc::new(State::default());
154 + let (agent_end, client_end) = Channel::duplex();
155 + let opts = ServeOptions {
156 + yolo: false,
157 + surface: "app",
158 + };
159 + tokio::spawn(ed_acp::serve(
160 + Arc::new(WithAuth(fake.clone())),
161 + opts,
162 + agent_end,
163 + ));
164 + let cwd = std::env::temp_dir();
165 +
166 + Client
167 + .builder()
168 + .connect_with(client_end, |conn: ConnectionTo<Agent>| async move {
169 + // The client cannot run terminal auth, and the method is offered anyway.
170 + let init = conn
171 + .send_request(InitializeRequest::new(ProtocolVersion::V1))
172 + .block_task()
173 + .await?;
174 + assert_eq!(init.auth_methods.len(), 1);
175 + assert_eq!(&*init.auth_methods[0].id().0, "fake");
176 +
177 + let signed_out = conn
178 + .send_request(NewSessionRequest::new(cwd.clone()))
179 + .block_task()
180 + .await;
181 + assert!(signed_out.is_err(), "no session while signed out");
182 +
183 + let wrong = conn
184 + .send_request(AuthenticateRequest::new("terminal-setup"))
185 + .block_task()
186 + .await;
187 + assert!(wrong.is_err(), "only the provider's method is accepted");
188 +
189 + conn.send_request(AuthenticateRequest::new("fake"))
190 + .block_task()
191 + .await?;
192 + conn.send_request(NewSessionRequest::new(cwd.clone()))
193 + .block_task()
194 + .await?;
195 +
196 + conn.send_request(LogoutRequest::new()).block_task().await?;
197 + let after = conn
198 + .send_request(NewSessionRequest::new(cwd.clone()))
199 + .block_task()
200 + .await;
201 + assert!(after.is_err(), "signed out again");
202 + Ok(())
203 + })
204 + .await
205 + .expect("conversation");
206 +
207 + assert_eq!(fake.authenticated.load(Ordering::SeqCst), 1);
208 + }
209 +}