diff --git a/docs/agent-mode-acp.md b/docs/agent-mode-acp.md new file mode 100644 index 000000000..a037f319f --- /dev/null +++ b/docs/agent-mode-acp.md @@ -0,0 +1,249 @@ +# Agent Mode ACP harness preview + +This document describes Maple's experimental Agent Client Protocol (ACP) surface. The first tested consumer is [Buzz](https://github.com/block/buzz), but the architectural boundary is Maple-owned: external harnesses adapt into Maple's existing Agent Mode runtime rather than starting a second Goose runtime. + +ACP is not Maple Agent's primary internal abstraction. Maple continues to embed Goose directly. The preview exists to answer a narrower question: can another local application control the real, signed-in Maple Agent through a standard agent protocol? + +## Architecture + +```mermaid +flowchart LR + Buzz["Buzz Desktop"] + Harness["Buzz ACP harness"] + Connector["maple acp\nstdio connector"] + Socket["owner-only\nUnix socket"] + Adapter["Maple ACP adapter"] + Runtime["Maple Agent runtime facade"] + Goose["embedded Goose AgentManager"] + Provider["MapleProvider + MapleApiSession"] + Tools["Maple developer, web, and skills clients"] + BuzzCli["Buzz CLI"] + + Buzz --> Harness + Harness -->|"ACP v1 over stdio"| Connector + Connector --> Socket + Socket --> Adapter + Adapter --> Runtime + Runtime --> Goose + Goose --> Provider + Goose --> Tools + Tools -->|"session-scoped BUZZ_* environment"| BuzzCli + BuzzCli --> Buzz +``` + +The packaged executable has two entry paths: + +- Normal invocation launches Maple Desktop. +- `maple acp` connects standard input/output to the already-running desktop process. + +Buzz owns the subprocess and supplies its relay context there. Maple Desktop owns authentication, the account-scoped provider, Goose managers, tasks, tools, permission policy, runs, and UI events. The connector sends no inference request itself and does not initialize a second agent runtime. + +The executable path is part of the socket name. This keeps an installed Maple build and independent development builds from accidentally sharing an ACP endpoint. + +## Why Goose ACP is not used directly + +Goose already implements a much broader ACP server, including a reusable byte-stream transport. At Maple's pinned Goose revision, however, `GooseAcpAgent::new` creates and owns fresh session, permission, and agent managers and reads Goose's global configuration. Its public construction path cannot attach the ACP projection to Maple's existing managers. + +Starting `goose acp`, enabling `goose serve`, or constructing a `GooseAcpAgent` after Maple initializes would therefore create a second runtime: + +```mermaid +flowchart TB + subgraph Separate["Standalone Goose ACP path"] + ClientA["ACP client"] --> GooseAcp["new GooseAcpAgent"] + GooseAcp --> ManagersA["new managers, config, tools, and permissions"] + end + + subgraph MaplePath["Required Maple path"] + ClientB["ACP client"] --> MapleAcp["Maple ACP adapter"] + MapleAcp --> ManagersB["existing account-scoped Maple runtime"] + ManagersB --> Auth["in-memory authenticated Maple provider"] + ManagersB --> MapleTools["Maple tools, policy, tasks, and UI events"] + end +``` + +A provider factory alone is insufficient. Maple also needs to preserve: + +- its authenticated `MapleApiSession` and caller-owned `MapleProvider`; +- account-scoped session storage and lifecycle fencing; +- model locking and session restoration behavior; +- `MapleDeveloperClient`, Maple web tools, and trust-filtered skills; +- Maple-local permission classification and approval UI; +- run cancellation, retained terminal state, and desktop timeline events; and +- ephemeral per-session tool context supplied by the external harness. + +Sharing a data directory between two managers would not make them one runtime and would introduce conflicting in-memory ownership. The preview therefore uses the standard `agent-client-protocol` crate for a deliberately narrow mapping into Maple-owned operations. + +## Current protocol surface + +`No` means "not implemented by this preview," not "fundamentally impossible." Effort is relative to the current branch: + +- **Low** is primarily ACP dispatch or projection over an existing Maple operation. +- **Medium** adds an account-scoped Maple runtime-facade operation, richer event contract, or lifecycle tests. +- **High** changes a security/product boundary or needs a broader structured-content/runtime abstraction. High does not necessarily imply a Buzz or Goose fork. + +Goose already implements many of these semantics, but at Maple's pinned revision its history replayer, response builders, tool converters, permission mapping, usage mapping, and handlers are private or `pub(crate)` and operate on concrete `GooseAcpAgent` state. Maple can port that behavior, or Goose could extract it, but Maple cannot currently plug its host-owned runtime into those handlers. + +| ACP operation or capability | Preview support | Remaining effort | Feasibility and limiting layer | +| ------------------------------------------ | --------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initialize` | Yes | Implemented | Negotiates ACP v1 and deliberately advertises only the narrow implemented capability set. New capabilities should be advertised only with their handlers and wire tests. | +| `session/new` | Yes | Implemented | Requires an absolute `cwd` and creates a real Maple task. Optional additional workspace directories and generic ACP-provided MCP servers are graded separately below. | +| Additional workspace directories | No | Medium | Supportable, but Maple currently has a single-root task and Skills-trust model. Correct support needs canonical admission against the configured roots, an explicit per-session root set, tool and Skills semantics for those roots, and consistent persistence/reporting across list, load, resume, and fork. This is a Maple workspace-model change, not a Goose loop limitation. | +| `session/prompt` text | Yes | Implemented | Accepts text blocks and permits one active prompt per session. Text annotations are not currently preserved. | +| `session/update` text and thought | Partial | Low–Medium | Assistant text and thought chunks are projected. Maple could incrementally add actual ACP variants such as user chunks, plans, available commands, modes/config, and session info. Full Goose fidelity is the sum of the richer tool, usage, and media rows below; Goose's mature projector cannot currently be called independently of `GooseAcpAgent`. | +| `session/cancel` | Yes | Implemented | Cancels Maple's underlying run and retains its terminal state. | +| `session/list` | No | Low | Supportable without rearchitecture. Maple already lists account-scoped persisted tasks and can filter by `cwd`. The adapter needs ACP field mapping and pagination plus a policy decision: may a same-user ACP client enumerate all Desktop tasks, or only ACP-created tasks? Restricting by origin would first require durable provenance because preview tasks are ordinary Maple user tasks. | +| `session/resume` | No | Medium | Supportable. Attach an existing same-account task without replay, validate its `cwd`, allowed roots, and active-run state, preserve its persisted model, and restore Maple provider/tool/permission state plus the current connection's transient environment. A service-global session lease is needed so two ACP clients cannot overwrite one task's credentials or tool context. This is a Maple lifecycle seam, not an agent-loop redesign. | +| `session/load` | No | Medium–High | Supportable. This is resume plus the protocol-required ordered replay before responding. Maple already loads a normalized timeline; faithful replay needs a separate ACP history projection for user/assistant/thought/tool rows, stable tool correlation, bounded content, and explicit treatment of media that Maple's UI timeline omits. Goose's existing replayer is useful upstream code to extract, but its activation path owns concrete Goose managers. | +| `session/close` | No | Low–Medium | Supportable. Cancel active work, revoke connection-scoped credentials, release ACP ownership, and detach/unload only what ACP owns while preserving the persisted Maple task. The main work is race-safe cleanup without disrupting the same task if Maple Desktop is using it. | +| `session/delete` | No | Low code; high policy | Maple already has a deletion operation, so the handler is straightforward. The product decision is consequential: whether ACP may destroy Desktop history, whether deletion is limited to ACP-created tasks, and whether an explicit opt-in is required. | +| `session/fork` | No | Medium | Goose's underlying `SessionManager` already has copy primitives, and its ACP implementation shows copy, optional truncation, and activation. Maple needs an account-scoped wrapper, root/policy validation, fresh transient context, activation, and rollback for partial failures. ACP fork is still unstable, so enabling it also accepts an unstable wire commitment; no Goose fork is otherwise required. | +| Session mode | No | Medium | Maple already changes per-session permission mode. ACP support needs advertised mode state, a handler, update projection, and an explicit rule about whether a client may select unattended approval. This is principally a Maple trust-policy decision. | +| Model selection and other config | No | Medium–High | A Maple model option is feasible before the first prompt using Maple's authenticated catalog. Maple intentionally locks a task's model once it has history, so arbitrary mid-session switching should be rejected or treated as a product change. The native ACP path also needs authoritative vision/context metadata. Goose's config builder cannot be reused because it assumes Goose's global provider inventory rather than Maple's caller-owned `MapleProvider`; provider switching should remain out of scope. | +| ACP permission requests | No | High | Technically supportable, but it changes the trust boundary. Maple already exposes pending one-shot decisions and can feed a response back to Goose. It would need exactly one authoritative broker per session—Maple UI, ACP client, or a deliberately designed hybrid—plus timeout, disconnect, cancellation, and double-response handling. The tested Buzz build automatically chooses `allow_once` for forwarded requests, so delegation would materially weaken the current local-approval boundary. Goose's mapping is not an injectable broker. | +| Basic structured tool calls and results | No | Low–Medium | Maple's timeline already carries stable IDs, title, input, output, status, and errors, which is enough for ordinary ACP `ToolCall` and `ToolCallUpdate` cards. This is mostly projection work. Buzz also treats the initial `tool_call` notification as activity, so projecting tool-call start would reset its idle watchdog during a long otherwise-silent tool, improving liveness as well as UI visibility. | +| Rich tool/resource/location/MCP projection | No | Medium–High | Rich image/resource content, file locations, progressive MCP notifications, and faithful replay need data below Maple's deliberately summarized UI timeline. A public transport-neutral Goose `AcpEventProjector` would avoid maintaining this nuanced mapping twice. | +| Terminal and diff parity | No | Medium–High; architectural choice | Goose's terminal handles and diff updates are not projection alone: its ACP filesystem layer replaces/delegates developer operations through ACP-client filesystem and terminal RPCs. Maple currently executes its own local tools. Maple would need either to synthesize bounded metadata from those results or delegate execution to the client, which would change its tool and permission architecture; an event projector by itself is insufficient. | +| Usage and context updates | No | Medium | Supportable. Goose emits and persists usage, but Maple currently discards ephemeral usage notifications before its public event stream. Maple must retain a protocol-neutral usage event or query post-turn totals, pair usage with the persisted context limit, and define cumulative semantics. ACP cost is optional and should remain absent until it matches Maple billing semantics. Buzz can display standard usage for observability, but its durable turn-accounting path currently consumes a Goose-private cumulative-usage notification instead. | +| Prompt images | No | Medium | The embedded Goose message model and `MapleProvider` already carry images. Maple's non-UI send facade is text-only and ACP forces `vision_capable: false`; support needs structured prompt input, MIME/base64/size limits, native model-capability resolution, and replay/UI policy. This is mainly a Maple facade change. | +| Prompt audio | No | High | Native audio is not a small adapter change. Pinned Goose has no audio message variant and its ACP conversion ignores audio. Maple could define a transcription-to-text pipeline, but native multimodal audio requires a Goose/provider/message abstraction change. This is the clearest current Goose-level content limitation. | +| Embedded text resources | No | Low–Medium | Supportable by flattening bounded content into a clearly labeled, untrusted prompt block while preserving URI/provenance metadata. | +| Embedded binary resources | No | Medium–High | Known image or document types could use explicit Maple ingestion paths. Arbitrary blobs have no generic model-facing representation and need type, decoding, size, staging, persistence, and rejection rules. | +| Resource links | No | Medium–High | Supportable with policy work, and currently a baseline ACP v1 gap because resource links have no opt-out capability. Local and remote links need separate scheme, root, symlink, size, encoding, permission, and provenance rules so prompt ingress cannot bypass Maple's filesystem/web controls. Goose's private helper only performs an unbounded local `file://` text read, which Maple should not copy literally. | +| Arbitrary ACP-provided MCP servers | No | High | Technically supportable, but production-safe support expands a code-execution and secret boundary. Maple must validate client-supplied commands, URLs, headers, and environments; define authorization and name-collision rules; attach them transiently without persisting secrets; and guarantee per-session process cleanup across close, disconnect, crash, and Flatpak constraints. Generic stdio MCP is an ACP v1 baseline, so the current Buzz-only adaptation is a real conformance gap. | +| Goose/Buzz native steering | No | Medium code; high coupling | Supportable but intentionally non-standard. Buzz uses `_goose/unstable/session/steer`, not an ACP v1 method. Goose's underlying `Agent::steer` queue is public; Maple needs an active-agent/run facade, `expectedRunId` validation, correlation updates, and prompt-end/cancel race tests. Buzz's cancel-and-merge fallback means this is not required for the tested flow, and adopting it would couple Maple to an unstable extension. | + +This is parity for the tested Maple task path, not parity with Goose's complete ACP implementation. + +Two ACP v1 baseline caveats are worth making explicit: agents must accept `ResourceLink` prompt blocks and stdio MCP definitions. The preview handles neither generically. The tested Buzz custom-harness path uses text prompts and does not depend on arbitrary MCP definitions; Maple also contains one exact Buzz compatibility adaptation. Image, audio, and embedded-resource capabilities are correctly advertised as unavailable. + +None of the `No` rows blocks the tested Buzz harness. That Buzz build does not call list/load/resume/fork/close, tolerates absent model/config options by using the agent default, and falls back from native steering to cancel-and-merge. Initial `tool_call` notifications would improve Buzz Desktop visibility and reset its idle watchdog during long tools. Standard usage would improve observability, while durable turn metrics currently rely on a Goose-private notification. Its signed channel reply remains a separate tool/CLI path. + +## Runtime facade + +The integration exposes a small non-UI interface around Maple Agent's existing runtime: + +- ensure the account-scoped runtime exists; +- create and delete a Maple task; +- send and cancel a run; +- subscribe to Maple Agent events; +- observe a retained terminal result even if the bounded event stream lags; and +- install and revoke per-session tool environments. + +These are useful host capabilities independent of ACP. Protocol types remain in the ACP adapter rather than becoming Maple Agent domain types. + +## Lifecycle and desktop configuration + +The macOS/Linux desktop settings page is intentionally manual. It can: + +- start and stop the local service; +- select the policy applied to newly created ACP sessions; +- show connected client, session, and active-run counts; +- copy the exact packaged executable path and `acp` argument; +- copy a Buzz custom-harness definition; +- show protocol, endpoint, and Buzz credential diagnostics; and +- warn that Buzz's default parallelism must be reduced to Maple's current default of one connection. + +Maple stops ACP before logout, local Agent-data clearing, Agent-runtime stop or restart, application update restart, and application exit. A saved `enabled` value does not auto-start ACP on the next launch; the user must explicitly start it again. + +Permission-mode changes require Stop, then change and save, then Start. This prevents a new session from racing a restrictive policy update and retaining the previous policy. + +## Permissions are policy, not confinement + +The persisted protocol-facing values currently have these meanings: + +- `read_only` means **require local approvals**. Maple maps it to its `smart_approve` path. A write-capable action can still occur after the user approves it in Maple Desktop. +- `allow_all` is unattended operation. The agent may run commands, modify files, and perform external actions without a second local confirmation. + +Neither mode is an operating-system sandbox. + +Native configuration supports a list of allowed project roots, but the preview UI does not expose it. An empty list accepts any absolute session working directory available to the Maple process. Even a configured root checks session admission, not every path later accessed by a read, edit, or shell tool. Do not describe this as filesystem confinement. + +## Local trust and credentials + +The Unix socket is mode `0600`; the Linux runtime directory is owner-checked and mode `0700`. There is no second application-level client credential. While the service is enabled, any process running as the same OS user and able to reach that endpoint is inside the local trust boundary and can use the signed-in Maple Agent. + +Maple and Buzz credentials follow different paths: + +- Maple access credentials stay inside Maple Desktop's authenticated provider session. They are not written to the ACP harness, command arguments, or child environment. +- Buzz relay credentials begin in the Buzz-owned connector process. The connector sends an internal `_maple/bridge/hello` notification over the local socket before normal ACP traffic. +- The bridge filters to five `BUZZ_*` variables plus `PATH`, rejects null bytes, and rejects values over 16 KiB. +- Filtered values remain in the ACP connection's in-memory context and are copied into per-session tool context. Session installation revalidates the six-key allowlist and enforces the 16-KiB-per-value and 32-KiB-total bounds. +- The context is revoked during session and connection cleanup. Credential-bearing shell process trees are terminated after each command to limit descendant retention. + +This design keeps Buzz credentials out of saved harness JSON, Maple configuration, argv, and Maple's process-global environment. It does **not** make the credentials invisible to the trusted agent or commands it runs. The shell needs the signing identity to publish a durable Buzz reply, and a command with that environment can inspect or transmit it. `allow_all` should therefore be enabled only for trusted clients, prompts, projects, and toolchains. + +Credential-bearing Buzz shell execution fails closed inside Flatpak. Host-executable Linux packaging and socket behavior still require end-to-end validation. + +## Buzz compatibility behavior + +The wire surface is standard ACP v1, but this first adapter includes explicit Buzz compatibility: + +- the private bridge hello for subprocess environment transfer; +- the Buzz relay variable allowlist; +- recognition of a narrowly shaped absolute `buzz-dev-mcp` stdio definition: matching server name and executable basename, no arguments, and an existing executable file; +- adaptation of that environment into Maple's existing developer shell instead of launching a second general-purpose shell server; +- a `Buzz ACP` Maple task title; +- custom-harness JSON and parallelism guidance in settings. + +If this integration is maintained, these concerns should move behind an explicit Buzz adapter rather than expanding Maple's protocol-neutral runtime facade. + +## End-to-end proof + +The exploratory validation used: + +- Maple with Goose pinned to `c3111c71cd682ed1d115741677f0ca9946c51499`; +- Buzz commit `3a4bf513df0e0c258587bfcbed9463d63723b56b`; +- a packaged arm64 macOS Maple development app; +- ACP v1; +- Buzz owner-only channel admission and parallelism `1`; and +- Maple's unattended `allow_all` policy. + +Two Buzz GUI tasks completed: + +1. A deterministic mention returned exactly `MAPLE-GUI-OK`. +2. A mention asked Maple to read the checkout's real `README.md` and explain the project. Maple used its local file tools and posted a substantive Buzz reply covering the Tauri/Bun architecture, platforms, TTS, PDF OCR, signing and updates, development prerequisites, and platform-specific setup notes. + +The second run took roughly two to three minutes and ended with zero active runs. That timing has not been profiled and should not be attributed to ACP. The result is manual compatibility evidence, not a performance, load, conformance, billing, or security test. + +## Known limitations + +- macOS is the only platform validated end to end. +- Windows, mobile, and web are unsupported; non-Flatpak Linux remains unvalidated. +- Service activation is manual after every Maple launch. +- The local-approval mode can leave an unattended Buzz task waiting for approval in Maple. +- Intermediate ACP output can be truncated if Maple's shared bounded event bus lags; terminal state is retained, but missed chunks are not reconstructed. +- An idle same-user socket can occupy the default one-connection limit; there is no initialization or idle timeout yet. +- Disconnecting keeps the Maple task, but ACP cannot reload it. Repeated connections can accumulate `Buzz ACP` tasks. +- Live changes to the configured maximum do not resize a listener that is already running. +- The UI polls service status instead of subscribing to lifecycle events. +- There is no checked-in wire-level, socket-lifecycle, reconnect, permission, or Buzz GUI integration fixture yet. +- The adapter intentionally omits much of Goose's ACP event and capability surface. + +## Maintenance recommendation + +Keep Maple's primary path direct: + +```text +Maple UI -> Maple runtime -> embedded Goose -> MapleProvider +``` + +Keep ACP at the edge: + +```text +External harness -> ACP adapter -> Maple runtime facade +``` + +Maintaining the bounded adapter is reasonable if Buzz or other external harnesses are strategically useful. Maple should not duplicate Goose's full ACP feature set speculatively, refactor primary Agent Mode around ACP, or fork Goose solely for this preview. + +Before broadening the local adapter, the preferred path is to pursue reusable Goose seams: + +1. construct ACP around existing `AgentManager`, `SessionManager`, and `PermissionManager` handles; +2. use an injectable provider resolver for new, restored, and reconfigured sessions; +3. add host session-admission, activation, prompt-preparation, and cleanup hooks; +4. make permission routing pluggable between the host UI, ACP client, or a hybrid policy; +5. let a host preserve its installed developer/tool clients; +6. support transient ACP-provided session context with explicit cleanup; +7. extract the Goose-event-to-ACP projection for use without runtime ownership; and +8. remove global configuration and path assumptions from the embedded path. + +Even with those changes, the small `maple acp` connector and local IPC boundary would remain: stdio and the Buzz-owned environment live in a spawned process, while Maple authentication lives in the running desktop process. diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 855202171..a51f17149 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -4587,6 +4587,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" name = "maple" version = "3.3.1" dependencies = [ + "agent-client-protocol", "anyhow", "async-trait", "axum", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 3f9e3974d..f8ba6775d 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "=2.4.0", features = ["deep-link"] } tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2.3.2" tauri-plugin-sign-in-with-apple = "1.0.2" -tokio = { version = "1.0", features = ["io-util", "net", "process", "sync", "rt-multi-thread", "macros", "time"] } +tokio = { version = "1.0", features = ["io-std", "io-util", "net", "process", "sync", "rt-multi-thread", "macros", "time"] } once_cell = "1.18.0" maple-proxy = "0.2.0" tauri-plugin-fs = "2.5.1" @@ -82,7 +82,8 @@ opensecret = "3.5.0" async-trait = "0.1" rmcp = { version = "=1.4.0", default-features = false } tauri-plugin-dialog = "2.7.1" -tokio-util = { version = "0.7", features = ["codec", "io"] } +tokio-util = { version = "0.7", features = ["codec", "compat", "io"] } +agent-client-protocol = { version = "=1.0.1", default-features = false } httpdate = "1" process-wrap = { version = "=9.1.0", default-features = false, features = ["tokio1", "creation-flags", "job-object", "process-group"] } pulldown-cmark = { version = "0.13", default-features = false } diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index fdca8fcfb..aac10ba7b 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -33,7 +33,7 @@ use shell_permission::{ local_read_image_request_id, local_read_request_id, ShellPermissionClassifier, ShellPermissionOutcome, ShellPermissionRequest, }; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -42,7 +42,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager, State}; -use tokio::sync::{oneshot, Mutex}; +use tokio::sync::{broadcast, oneshot, watch, Mutex, RwLock}; use tokio_util::sync::CancellationToken; use web_permission::{ web_search_request_id, OpenUrlPermissionRequest, WebPermissionClassifier, WebPermissionContext, @@ -92,8 +92,24 @@ const MAX_MCP_CONNECTION_ERRORS: usize = 3; const MAX_MCP_SERVER_NAME_CHARS: usize = 64; const MAX_MCP_CONNECTION_ERROR_CHARS: usize = 200; const MCP_CONNECTION_ERROR_PREFIX: &str = "Some MCP servers could not connect:"; +const AGENT_EVENT_BROADCAST_CAPACITY: usize = 1_024; +const MAX_AGENT_TOOL_ENV_KEYS: usize = 6; +const MAX_AGENT_TOOL_ENV_KEY_BYTES: usize = 64; +const MAX_AGENT_TOOL_ENV_VALUE_BYTES: usize = 16 * 1024; +const MAX_AGENT_TOOL_ENV_TOTAL_BYTES: usize = 32 * 1024; +const ALLOWED_AGENT_TOOL_ENV_KEYS: [&str; MAX_AGENT_TOOL_ENV_KEYS] = [ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_DISPLAY_NAME", + "PATH", +]; static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1); +pub(crate) type AgentToolEnvironment = BTreeMap; +pub(crate) type SharedAgentToolEnvironment = Arc>; + fn validate_session_model_lock( message_count: usize, persisted_model: Option<&str>, @@ -304,6 +320,18 @@ pub struct AgentRunResponse { pub run_id: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentRunTerminal { + Completed, + Cancelled, + Failed, +} + +pub(crate) struct AgentRunHandle { + pub run_id: String, + pub terminal: watch::Receiver>, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { @@ -381,6 +409,7 @@ struct AgentRuntime { session_manager: Arc, maple_api_session: Arc, active_runs: HashMap, + session_tool_environments: HashMap, permission_modes: SessionPermissionModes, web_tool_state: Arc, project_root: PathBuf, @@ -412,6 +441,7 @@ pub struct AgentRuntimeState { session_lifecycle: Arc>, pending_permissions: PendingPermissions, live_timelines: LiveTimelines, + event_sender: broadcast::Sender, } type LiveTimelines = Arc>>; @@ -450,6 +480,7 @@ impl LiveTimeline { impl AgentRuntimeState { pub fn new() -> Self { + let (event_sender, _) = broadcast::channel(AGENT_EVENT_BROADCAST_CAPACITY); Self { inner: Arc::new(Mutex::new(None)), runtime_lifecycle: Arc::new(Mutex::new(())), @@ -457,10 +488,20 @@ impl AgentRuntimeState { session_lifecycle: Arc::new(Mutex::new(())), pending_permissions: Arc::new(Mutex::new(HashMap::new())), live_timelines: Arc::new(Mutex::new(HashMap::new())), + event_sender, } } } +pub(crate) fn subscribe_agent_events( + app_handle: &AppHandle, +) -> broadcast::Receiver { + app_handle + .state::() + .event_sender + .subscribe() +} + fn ensure_runtime_account(runtime: &AgentRuntime, account_scope: &str) -> Result<(), String> { ensure_account_scope(&runtime.account_scope, account_scope) } @@ -722,6 +763,98 @@ pub async fn agent_start_runtime( start_runtime_for_user(app_handle, &state, &api_auth_state, user_id, request).await } +pub(crate) async fn ensure_agent_runtime_for_user( + app_handle: &AppHandle, + user_id: String, + request: Option, +) -> Result { + agent_start_runtime( + app_handle.clone(), + app_handle.state::(), + app_handle.state::(), + user_id, + request, + ) + .await +} + +pub(crate) async fn set_agent_session_tool_environment( + app_handle: &AppHandle, + user_id: &str, + session_id: &str, + environment: HashMap, +) -> Result { + let environment = normalize_agent_tool_environment(environment)?; + let session_id = session_id.trim().to_string(); + if session_id.is_empty() { + return Err("Agent tool environment requires a task ID".to_string()); + } + + let state = app_handle.state::(); + let requested_scope = account_scope(user_id)?; + let generation = account_generation(&state, &requested_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &requested_scope, generation).await?; + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let session_manager = { + let runtime = state.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &requested_scope)?; + Arc::clone(¤t.session_manager) + }; + session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to load Agent task: {error}"))?; + + let shared_environment = { + let mut runtime = state.inner.lock().await; + let current = runtime + .as_mut() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &requested_scope)?; + Arc::clone( + current + .session_tool_environments + .entry(session_id) + .or_insert_with(|| Arc::new(RwLock::new(AgentToolEnvironment::new()))), + ) + }; + *shared_environment.write().await = environment; + Ok(shared_environment) +} + +pub(crate) async fn clear_agent_session_tool_environment( + app_handle: &AppHandle, + user_id: &str, + session_id: &str, +) -> Result<(), String> { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Ok(()); + } + + let state = app_handle.state::(); + let requested_scope = account_scope(user_id)?; + let generation = account_generation(&state, &requested_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &requested_scope, generation).await?; + let removed = { + let mut runtime = state.inner.lock().await; + let Some(current) = runtime.as_mut() else { + return Ok(()); + }; + ensure_runtime_account(current, &requested_scope)?; + current.session_tool_environments.remove(session_id) + }; + if let Some(environment) = removed { + environment.write().await.clear(); + } + Ok(()) +} + async fn start_runtime_for_user( app_handle: AppHandle, state: &AgentRuntimeState, @@ -808,6 +941,7 @@ async fn start_runtime_for_user( session_manager, maple_api_session, active_runs: HashMap::new(), + session_tool_environments: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), project_root: project_root.clone(), @@ -847,9 +981,11 @@ async fn start_runtime_for_user( #[tauri::command] pub async fn agent_stop_runtime( + app_handle: AppHandle, state: State<'_, AgentRuntimeState>, user_id: String, ) -> Result { + crate::agent_acp::shutdown_agent_acp(&app_handle).await?; let account_scope = account_scope(&user_id)?; let generation = account_generation(&state, &account_scope).await; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; @@ -866,6 +1002,7 @@ pub async fn agent_restart_runtime( user_id: String, request: Option, ) -> Result { + crate::agent_acp::shutdown_agent_acp(&app_handle).await?; let account_scope = account_scope(&user_id)?; let generation = account_generation(&state, &account_scope).await; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; @@ -880,6 +1017,7 @@ pub async fn agent_clear_user_data( state: State<'_, AgentRuntimeState>, user_id: String, ) -> Result<(), String> { + crate::agent_acp::shutdown_agent_acp(&app_handle).await?; let requested_scope = account_scope(&user_id)?; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; advance_account_generation(&state, &requested_scope).await; @@ -911,6 +1049,7 @@ pub async fn agent_clear_user_data( )) } } + crate::agent_acp::clear_agent_acp_config(&app_handle, &user_id)?; Ok(()) } @@ -920,6 +1059,7 @@ pub async fn agent_clear_user_history( state: State<'_, AgentRuntimeState>, user_id: String, ) -> Result<(), String> { + crate::agent_acp::shutdown_agent_acp(&app_handle).await?; let requested_scope = account_scope(&user_id)?; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; advance_account_generation(&state, &requested_scope).await; @@ -1276,6 +1416,17 @@ pub async fn agent_create_session( .lock() .await .insert(session.id.clone(), permission_mode); + let tool_environment = Arc::new(RwLock::new(AgentToolEnvironment::new())); + { + let mut runtime = state.inner.lock().await; + let current = runtime + .as_mut() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &account_scope)?; + current + .session_tool_environments + .insert(session.id.clone(), Arc::clone(&tool_environment)); + } let setup_result: Result, String> = async { let (agent, mut mcp_errors) = configure_session_agent( AgentSkillsScope { @@ -1292,6 +1443,7 @@ pub async fn agent_create_session( context_limit: request.context_limit, mode: &mode, primary_model_supports_vision: false, + tool_environment: &tool_environment, }, ) .await?; @@ -1322,6 +1474,18 @@ pub async fn agent_create_session( Ok(mcp_errors) => mcp_errors, Err(error) => { permission_modes.lock().await.remove(&session.id); + let removed_tool_environment = { + let mut runtime = state.inner.lock().await; + if let Some(current) = runtime.as_mut() { + ensure_runtime_account(current, &account_scope)?; + current.session_tool_environments.remove(&session.id) + } else { + None + } + }; + if let Some(environment) = removed_tool_environment { + environment.write().await.clear(); + } if let Err(cleanup_error) = session_manager.delete_session(&session.id).await { log::warn!( "Failed to remove Agent task {} after setup error: {cleanup_error}", @@ -1663,6 +1827,18 @@ pub async fn agent_delete_session( if let Some(permission_modes) = permission_modes { permission_modes.lock().await.remove(&session_id); } + let removed_tool_environment = { + let mut runtime = state.inner.lock().await; + if let Some(current) = runtime.as_mut() { + ensure_runtime_account(current, &account_scope)?; + current.session_tool_environments.remove(&session_id) + } else { + None + } + }; + if let Some(environment) = removed_tool_environment { + environment.write().await.clear(); + } Ok(()) } @@ -1886,13 +2062,12 @@ fn is_goose_declined_tool_response(response: &goose::conversation::message::Tool }) } -#[tauri::command] -pub async fn agent_send_message( +async fn agent_send_message_inner( app_handle: AppHandle, state: State<'_, AgentRuntimeState>, user_id: String, request: AgentSendMessageRequest, -) -> Result { +) -> Result { let account_scope = account_scope(&user_id)?; let generation = account_generation(&state, &account_scope).await; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; @@ -1914,20 +2089,28 @@ pub async fn agent_send_message( maple_api_session, permission_modes, web_tool_state, + tool_environment, model, mode, ) = { - let runtime = state.inner.lock().await; + let mut runtime = state.inner.lock().await; let current = runtime - .as_ref() + .as_mut() .ok_or_else(|| "Agent runtime is not running".to_string())?; ensure_runtime_account(current, &account_scope)?; + let tool_environment = Arc::clone( + current + .session_tool_environments + .entry(request.session_id.clone()) + .or_insert_with(|| Arc::new(RwLock::new(AgentToolEnvironment::new()))), + ); ( Arc::clone(¤t.agent_manager), Arc::clone(¤t.session_manager), Arc::clone(¤t.maple_api_session), Arc::clone(¤t.permission_modes), Arc::clone(¤t.web_tool_state), + tool_environment, request .model .clone() @@ -2013,6 +2196,7 @@ pub async fn agent_send_message( context_limit: request.context_limit, mode: &effective_mode, primary_model_supports_vision: request.vision_capable, + tool_environment: &tool_environment, }, ) .await?; @@ -2061,6 +2245,7 @@ pub async fn agent_send_message( let cancelled_permission_ids = Arc::new(Mutex::new(HashSet::new())); let task_cancelled_permission_ids = Arc::clone(&cancelled_permission_ids); let (start_tx, start_rx) = oneshot::channel(); + let (terminal_tx, terminal_rx) = watch::channel(None); let task = tauri::async_runtime::spawn(async move { let should_run = tokio::select! { biased; @@ -2160,6 +2345,16 @@ pub async fn agent_send_message( message: Some(status.to_string()), }, ); + // This retained per-run signal is authoritative for non-UI consumers. + // It is deliberately published after runFinished so a receiver that + // can still drain the broadcast stream observes all timeline chunks + // before settling, while a lagged receiver can never miss completion. + let terminal = match status { + "cancelled" => AgentRunTerminal::Cancelled, + "failed" => AgentRunTerminal::Failed, + _ => AgentRunTerminal::Completed, + }; + let _ = terminal_tx.send(Some(terminal)); // Remove the stored JoinHandle only after the final externally visible // side effect. Stop may otherwise miss this task and return while its // runFinished event is still pending. @@ -2227,7 +2422,21 @@ pub async fn agent_send_message( // followed by this send path re-appending the cancelled prompt. drop(session_lifecycle_guard); - Ok(AgentRunResponse { run_id }) + Ok(AgentRunHandle { + run_id, + terminal: terminal_rx, + }) +} + +#[tauri::command] +pub async fn agent_send_message( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + request: AgentSendMessageRequest, +) -> Result { + let run = agent_send_message_inner(app_handle, state, user_id, request).await?; + Ok(AgentRunResponse { run_id: run.run_id }) } #[tauri::command] @@ -2531,6 +2740,62 @@ pub async fn agent_permission_respond( Ok(()) } +pub(crate) async fn create_agent_session_for_user( + app_handle: &AppHandle, + user_id: String, + request: Option, +) -> Result { + agent_create_session( + app_handle.clone(), + app_handle.state::(), + user_id, + request, + ) + .await +} + +pub(crate) async fn delete_agent_session_for_user( + app_handle: &AppHandle, + user_id: String, + session_id: String, +) -> Result<(), String> { + agent_delete_session( + app_handle.clone(), + app_handle.state::(), + user_id, + session_id, + ) + .await +} + +pub(crate) async fn send_agent_message_for_user( + app_handle: &AppHandle, + user_id: String, + request: AgentSendMessageRequest, +) -> Result { + agent_send_message_inner( + app_handle.clone(), + app_handle.state::(), + user_id, + request, + ) + .await +} + +pub(crate) async fn cancel_agent_run_for_user( + app_handle: &AppHandle, + user_id: String, + run_id: String, +) -> Result<(), String> { + agent_cancel_run( + app_handle.clone(), + app_handle.state::(), + user_id, + run_id, + ) + .await +} + struct AgentPromptRun { app_handle: AppHandle, agent: Arc, @@ -3295,6 +3560,7 @@ struct SessionAgentConfiguration<'a> { context_limit: Option, mode: &'a str, primary_model_supports_vision: bool, + tool_environment: &'a SharedAgentToolEnvironment, } fn maple_model_config( @@ -3401,6 +3667,7 @@ async fn configure_session_agent( context_limit, mode, primary_model_supports_vision, + tool_environment, } = configuration; let session_mcp_keys = session_mcp_extension_keys(session); let manager_result = get_or_create_session_agent( @@ -3444,6 +3711,7 @@ async fn configure_session_agent( primary_model_supports_vision, web_transport, Arc::clone(web_tool_state), + tool_environment.clone(), ) .map_err(|e| format!("Failed to create Maple developer tools: {e}"))?; agent @@ -4414,6 +4682,8 @@ async fn update_live_permission_status( } fn emit_agent_event(app_handle: &AppHandle, event: AgentEventEnvelope) { + let state = app_handle.state::(); + let _ = state.event_sender.send(event.clone()); if let Err(error) = app_handle.emit(AGENT_EVENT_NAME, event) { log::warn!("Failed to emit Agent Mode event: {error}"); } @@ -4515,6 +4785,52 @@ fn parse_user_permission_mode(mode: &str) -> Result { } } +fn normalize_agent_tool_environment( + environment: HashMap, +) -> Result { + if environment.len() > MAX_AGENT_TOOL_ENV_KEYS { + return Err(format!( + "Agent tool environment supports at most {MAX_AGENT_TOOL_ENV_KEYS} variables" + )); + } + + let mut total_bytes = 0usize; + let mut normalized = AgentToolEnvironment::new(); + for (key, value) in environment { + if key.len() > MAX_AGENT_TOOL_ENV_KEY_BYTES { + return Err(format!( + "Agent tool environment variable names must be at most {MAX_AGENT_TOOL_ENV_KEY_BYTES} bytes" + )); + } + if !ALLOWED_AGENT_TOOL_ENV_KEYS.contains(&key.as_str()) { + return Err(format!( + "Agent tool environment variable {key} is not allowed" + )); + } + if value.contains('\0') { + return Err(format!( + "Agent tool environment variable {key} contains an invalid null byte" + )); + } + let value_bytes = value.len(); + if value_bytes > MAX_AGENT_TOOL_ENV_VALUE_BYTES { + return Err(format!( + "Agent tool environment variable {key} exceeds the {MAX_AGENT_TOOL_ENV_VALUE_BYTES} byte limit" + )); + } + total_bytes = total_bytes + .checked_add(key.len() + value_bytes) + .ok_or_else(|| "Agent tool environment size overflowed".to_string())?; + if total_bytes > MAX_AGENT_TOOL_ENV_TOTAL_BYTES { + return Err(format!( + "Agent tool environment exceeds the {MAX_AGENT_TOOL_ENV_TOTAL_BYTES} byte total limit" + )); + } + normalized.insert(key, value); + } + Ok(normalized) +} + fn normalize_mcp_servers(mut servers: Vec) -> Result, String> { let mut names = HashSet::new(); @@ -5478,6 +5794,64 @@ mod tests { assert!(config.project_skills_trust.is_empty()); } + #[test] + fn agent_tool_environment_accepts_only_the_bounded_buzz_allowlist() { + let allowed = ALLOWED_AGENT_TOOL_ENV_KEYS + .iter() + .map(|key| ((*key).to_string(), format!("value-for-{key}"))) + .collect::>(); + let normalized = normalize_agent_tool_environment(allowed).unwrap(); + assert_eq!( + normalized.keys().map(String::as_str).collect::>(), + vec![ + "BUZZ_ACP_DISPLAY_NAME", + "BUZZ_API_TOKEN", + "BUZZ_AUTH_TAG", + "BUZZ_PRIVATE_KEY", + "BUZZ_RELAY_URL", + "PATH", + ] + ); + + let unknown = HashMap::from([("HOME".to_string(), "/tmp/not-allowed".to_string())]); + assert!(normalize_agent_tool_environment(unknown) + .unwrap_err() + .contains("HOME is not allowed")); + + let nul = HashMap::from([("BUZZ_AUTH_TAG".to_string(), "bad\0value".to_string())]); + assert!(normalize_agent_tool_environment(nul) + .unwrap_err() + .contains("null byte")); + } + + #[test] + fn agent_tool_environment_enforces_value_and_total_size_limits() { + let oversized_value = HashMap::from([( + "BUZZ_PRIVATE_KEY".to_string(), + "x".repeat(MAX_AGENT_TOOL_ENV_VALUE_BYTES + 1), + )]); + assert!(normalize_agent_tool_environment(oversized_value) + .unwrap_err() + .contains("byte limit")); + + let per_value = (MAX_AGENT_TOOL_ENV_TOTAL_BYTES / 3) + 1; + let oversized_total = HashMap::from([ + ("BUZZ_PRIVATE_KEY".to_string(), "a".repeat(per_value)), + ("BUZZ_API_TOKEN".to_string(), "b".repeat(per_value)), + ("BUZZ_AUTH_TAG".to_string(), "c".repeat(per_value)), + ]); + assert!(normalize_agent_tool_environment(oversized_total) + .unwrap_err() + .contains("total limit")); + + let too_many = (0..=MAX_AGENT_TOOL_ENV_KEYS) + .map(|index| (format!("KEY_{index}"), "value".to_string())) + .collect::>(); + assert!(normalize_agent_tool_environment(too_many) + .unwrap_err() + .contains("at most")); + } + #[test] fn removed_project_roots_round_trip_only_through_device_local_storage() { let test_root = recent_roots_test_dir("device-local-removed-roots"); diff --git a/frontend/src-tauri/src/agent/developer_tools.rs b/frontend/src-tauri/src/agent/developer_tools.rs index 324c476c9..bd91e1422 100644 --- a/frontend/src-tauri/src/agent/developer_tools.rs +++ b/frontend/src-tauri/src/agent/developer_tools.rs @@ -28,7 +28,7 @@ use rmcp::model::{ }; use rmcp::object; use serde::{de::Error as SerdeDeError, Deserialize, Deserializer}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -39,7 +39,7 @@ use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt}; #[cfg(not(windows))] use tokio::sync::OnceCell; -use tokio::sync::{mpsc, Mutex}; +use tokio::sync::{mpsc, Mutex, RwLock}; use tokio_util::sync::CancellationToken; #[cfg(windows)] use windows::Win32::System::Threading::CREATE_NO_WINDOW; @@ -51,6 +51,13 @@ const MAX_READ_BYTES: usize = 50 * 1024; const MAX_EDIT_BYTES: usize = 20 * 1024 * 1024; const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; const MAX_SHELL_OUTPUT_BYTES: usize = 50_000; +const SESSION_SECRET_ENV_KEYS: [&str; 5] = [ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_DISPLAY_NAME", +]; const SHELL_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500); const SHELL_PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(10); const IMAGE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(30); @@ -132,6 +139,7 @@ pub(crate) struct MapleDeveloperClient { goose: DeveloperClient, web_transport: Arc, web_state: Arc, + tool_environment: Arc>>, contextual_image_context: Option, #[cfg(not(windows))] login_path_probe: ShellTool, @@ -145,6 +153,7 @@ impl MapleDeveloperClient { primary_model_supports_vision: bool, web_transport: Arc, web_state: Arc, + tool_environment: Arc>>, ) -> anyhow::Result { let info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("developer", "1.0.0").with_title("Developer")) @@ -160,6 +169,7 @@ impl MapleDeveloperClient { goose: DeveloperClient::new(context)?, web_transport, web_state, + tool_environment, contextual_image_context, #[cfg(not(windows))] login_path_probe: ShellTool::new(true)?, @@ -478,11 +488,13 @@ impl McpClientTrait for MapleDeveloperClient { let login_path = self.login_path().await; #[cfg(windows)] let login_path: Option = None; + let tool_environment = self.tool_environment.read().await.clone(); return Ok(run_bounded_shell( params, working_dir, login_path.as_deref(), Some(&ctx.session_id), + &tool_environment, cancel_token, ) .await); @@ -816,6 +828,7 @@ async fn run_bounded_shell( working_dir: Option<&Path>, login_path: Option<&str>, session_id: Option<&str>, + tool_environment: &BTreeMap, cancel_token: CancellationToken, ) -> CallToolResult { if params.command.trim().is_empty() { @@ -829,6 +842,7 @@ async fn run_bounded_shell( working_dir, login_path, session_id, + tool_environment, cancel_token, ) .await @@ -853,10 +867,24 @@ async fn execute_bounded_shell( working_dir: Option<&Path>, login_path: Option<&str>, session_id: Option<&str>, + tool_environment: &BTreeMap, cancel_token: CancellationToken, ) -> Result { - let mut command = - build_bounded_shell_command(command_line, working_dir, login_path, session_id); + let credential_bearing = contains_session_secret(tool_environment); + #[cfg(not(windows))] + if Path::new("/.flatpak-info").exists() && credential_bearing { + return Err( + "Credential-bearing Buzz ACP shell sessions are not supported inside Flatpak" + .to_string(), + ); + } + let mut command = build_bounded_shell_command( + command_line, + working_dir, + login_path, + session_id, + tool_environment, + ); command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -964,7 +992,7 @@ async fn execute_bounded_shell( // the cap. Keep the wrapped process-tree handle alive and kill it even if // the parent wait already completed. A quiet background job that merely // holds the pipes open follows Pi/Goose semantics and is left running. - if capture.exceeded_limit { + if capture.exceeded_limit || credential_bearing { let _ = terminate_shell_process(child.as_mut()).await; } if let Some(error) = wait_error { @@ -979,6 +1007,12 @@ async fn execute_bounded_shell( }) } +fn contains_session_secret(tool_environment: &BTreeMap) -> bool { + SESSION_SECRET_ENV_KEYS + .iter() + .any(|key| tool_environment.contains_key(*key)) +} + async fn wait_for_shell_parent(child: &mut dyn ChildWrapper) -> std::io::Result { // JobObject::wait waits for every Windows descendant, but Pi and Goose let // a successfully backgrounded process outlive the shell tool. try_wait @@ -1069,6 +1103,7 @@ fn build_bounded_shell_command( working_dir: Option<&Path>, login_path: Option<&str>, session_id: Option<&str>, + tool_environment: &BTreeMap, ) -> tokio::process::Command { #[cfg(windows)] let mut command = { @@ -1096,6 +1131,7 @@ fn build_bounded_shell_command( if let Some(path) = login_path { command.env("PATH", path); } + apply_tool_environment(&mut command, tool_environment); command }; @@ -1117,6 +1153,7 @@ fn build_bounded_shell_command( command.arg(format!("--env=PATH={path}")); } apply_flatpak_session_environment(&mut command, session_id); + apply_flatpak_tool_environment(&mut command, tool_environment); command.arg(shell).args(["-c", command_line]); command } else { @@ -1129,6 +1166,7 @@ fn build_bounded_shell_command( command.env("PATH", path); } apply_session_environment(&mut command, session_id); + apply_tool_environment(&mut command, tool_environment); command } }; @@ -1149,6 +1187,16 @@ fn apply_session_environment(command: &mut tokio::process::Command, session_id: } } +fn apply_tool_environment( + command: &mut tokio::process::Command, + tool_environment: &BTreeMap, +) { + for key in SESSION_SECRET_ENV_KEYS { + command.env_remove(key); + } + command.envs(tool_environment); +} + #[cfg(not(windows))] fn apply_flatpak_session_environment( command: &mut tokio::process::Command, @@ -1161,6 +1209,19 @@ fn apply_flatpak_session_environment( } } +#[cfg(not(windows))] +fn apply_flatpak_tool_environment( + command: &mut tokio::process::Command, + tool_environment: &BTreeMap, +) { + for key in SESSION_SECRET_ENV_KEYS { + command.arg(format!("--unset-env={key}")); + } + for (key, value) in tool_environment { + command.arg(format!("--env={key}={value}")); + } +} + #[cfg(not(windows))] fn executable_on_path(name: &str) -> Option { std::env::var_os("PATH") @@ -2253,11 +2314,20 @@ mod tests { } fn test_client(data_dir: PathBuf, primary_model_supports_vision: bool) -> MapleDeveloperClient { + test_client_with_environment(data_dir, primary_model_supports_vision, BTreeMap::new()) + } + + fn test_client_with_environment( + data_dir: PathBuf, + primary_model_supports_vision: bool, + tool_environment: BTreeMap, + ) -> MapleDeveloperClient { MapleDeveloperClient::new( test_context(data_dir), primary_model_supports_vision, Arc::new(TestWebTransport), Arc::new(WebToolState::default()), + Arc::new(RwLock::new(tool_environment)), ) .unwrap() } @@ -2386,6 +2456,7 @@ mod tests { true, Arc::new(TestWebTransport), Arc::new(WebToolState::default()), + Arc::new(RwLock::new(BTreeMap::new())), ) .unwrap(); let config = goose::agents::ExtensionConfig::Builtin { @@ -2914,6 +2985,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &BTreeMap::new(), CancellationToken::new(), ), ) @@ -2958,6 +3030,61 @@ mod tests { assert_eq!(cleared_value, Some(None)); } + #[test] + fn shell_tool_environment_is_applied_only_to_the_child_command() { + let process_value = std::env::var_os("BUZZ_ACP_DISPLAY_NAME"); + let environment = BTreeMap::from([ + ( + "BUZZ_ACP_DISPLAY_NAME".to_string(), + "maple-test-agent".to_string(), + ), + ("BUZZ_AUTH_TAG".to_string(), "test-auth-tag".to_string()), + ]); + let mut command = tokio::process::Command::new("unused"); + apply_tool_environment(&mut command, &environment); + + let command_environment = command + .as_std() + .get_envs() + .filter_map(|(key, value)| { + Some((key.to_str()?.to_string(), value?.to_str()?.to_string())) + }) + .collect::>(); + assert_eq!(command_environment, environment); + assert_eq!(std::env::var_os("BUZZ_ACP_DISPLAY_NAME"), process_value); + } + + #[cfg(not(windows))] + #[test] + fn flatpak_shell_forwards_each_tool_environment_value_as_one_argument() { + let environment = BTreeMap::from([ + ( + "BUZZ_ACP_DISPLAY_NAME".to_string(), + "Maple Agent".to_string(), + ), + ("BUZZ_PRIVATE_KEY".to_string(), "key=value".to_string()), + ]); + let mut command = tokio::process::Command::new("flatpak-spawn"); + apply_flatpak_tool_environment(&mut command, &environment); + let arguments = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + arguments, + vec![ + "--unset-env=BUZZ_RELAY_URL", + "--unset-env=BUZZ_PRIVATE_KEY", + "--unset-env=BUZZ_AUTH_TAG", + "--unset-env=BUZZ_API_TOKEN", + "--unset-env=BUZZ_ACP_DISPLAY_NAME", + "--env=BUZZ_ACP_DISPLAY_NAME=Maple Agent", + "--env=BUZZ_PRIVATE_KEY=key=value", + ] + ); + } + #[cfg(unix)] #[tokio::test] async fn shell_tool_forwards_the_current_agent_session_id() { @@ -2982,6 +3109,91 @@ mod tests { assert_eq!(output.stdout, "maple-task-456"); } + #[cfg(unix)] + #[tokio::test] + async fn shell_tool_environments_are_isolated_between_maple_sessions() { + let temp = TestDir::new(); + let first = test_client_with_environment( + temp.path().join("first-sessions"), + true, + BTreeMap::from([( + "BUZZ_ACP_DISPLAY_NAME".to_string(), + "first-maple-agent".to_string(), + )]), + ); + let second = test_client_with_environment( + temp.path().join("second-sessions"), + true, + BTreeMap::from([( + "BUZZ_ACP_DISPLAY_NAME".to_string(), + "second-maple-agent".to_string(), + )]), + ); + + for (client, session_id, expected) in [ + (&first, "maple-first", "first-maple-agent"), + (&second, "maple-second", "second-maple-agent"), + ] { + let result = client + .call_tool( + &ToolCallContext::new(session_id.to_string(), None, None), + "shell", + Some(object!({ + "command": "printf %s \"$BUZZ_ACP_DISPLAY_NAME\"", + "timeout_secs": 2 + })), + CancellationToken::new(), + ) + .await + .unwrap(); + let output: ShellOutput = + serde_json::from_value(result.structured_content.clone().unwrap()).unwrap(); + assert_eq!(result.is_error, Some(false)); + assert_eq!(output.stdout, expected); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn shell_tool_environment_clear_is_observed_by_an_existing_client() { + let temp = TestDir::new(); + let tool_environment = Arc::new(RwLock::new(BTreeMap::from([( + "BUZZ_AUTH_TAG".to_string(), + "maple-session-auth-tag".to_string(), + )]))); + let client = MapleDeveloperClient::new( + test_context(temp.path().join("sessions")), + true, + Arc::new(TestWebTransport), + Arc::new(WebToolState::default()), + Arc::clone(&tool_environment), + ) + .unwrap(); + let context = ToolCallContext::new("maple-clear-test".to_string(), None, None); + let call_shell = || { + client.call_tool( + &context, + "shell", + Some(object!({ + "command": "printf %s \"${BUZZ_AUTH_TAG-}\"", + "timeout_secs": 2 + })), + CancellationToken::new(), + ) + }; + + let result = call_shell().await.unwrap(); + let output: ShellOutput = + serde_json::from_value(result.structured_content.clone().unwrap()).unwrap(); + assert_eq!(output.stdout, "maple-session-auth-tag"); + + tool_environment.write().await.clear(); + let result = call_shell().await.unwrap(); + let output: ShellOutput = + serde_json::from_value(result.structured_content.clone().unwrap()).unwrap(); + assert_eq!(output.stdout, ""); + } + #[cfg(unix)] #[tokio::test] async fn shell_returns_without_killing_a_successful_background_job() { @@ -2999,6 +3211,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &BTreeMap::new(), CancellationToken::new(), ), ) @@ -3014,6 +3227,45 @@ mod tests { assert_eq!(fs::read_to_string(&sentinel).unwrap(), "survived"); } + #[cfg(unix)] + #[tokio::test] + async fn shell_kills_background_descendants_with_non_private_key_session_secrets() { + let temp = TestDir::new(); + let sentinel = temp.path().join("credential-descendant-survived"); + let command = format!("(sleep 1; printf survived > '{}') &", sentinel.display()); + let environment = BTreeMap::from([( + "BUZZ_AUTH_TAG".to_string(), + "maple-session-auth-tag".to_string(), + )]); + + let result = tokio::time::timeout( + Duration::from_secs(3), + run_bounded_shell( + ShellParams { + command, + timeout_secs: Some(2), + }, + None, + std::env::var("PATH").ok().as_deref(), + None, + &environment, + CancellationToken::new(), + ), + ) + .await + .expect("a credential-bearing background tree must be terminated"); + let output: ShellOutput = + serde_json::from_value(result.structured_content.clone().unwrap()).unwrap(); + assert_eq!(result.is_error, Some(false)); + assert!(output.output_truncated); + + tokio::time::sleep(Duration::from_secs(1)).await; + assert!( + !sentinel.exists(), + "a background descendant retained a non-private-key session secret" + ); + } + #[cfg(unix)] #[tokio::test] async fn shell_kills_a_noisy_background_tree_after_the_parent_exits() { @@ -3034,6 +3286,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &BTreeMap::new(), CancellationToken::new(), ), ) @@ -3069,6 +3322,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &BTreeMap::new(), CancellationToken::new(), ) .await; @@ -3107,6 +3361,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &BTreeMap::new(), cancel_token, ) .await; @@ -3132,6 +3387,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &BTreeMap::new(), CancellationToken::new(), ) .await; diff --git a/frontend/src-tauri/src/agent_acp.rs b/frontend/src-tauri/src/agent_acp.rs new file mode 100644 index 000000000..17116928a --- /dev/null +++ b/frontend/src-tauri/src/agent_acp.rs @@ -0,0 +1,1547 @@ +use crate::agent::{ + cancel_agent_run_for_user, clear_agent_session_tool_environment, create_agent_session_for_user, + delete_agent_session_for_user, ensure_agent_runtime_for_user, send_agent_message_for_user, + set_agent_session_tool_environment, subscribe_agent_events, AgentCreateSessionRequest, + AgentEventEnvelope, AgentRunTerminal, AgentSendMessageRequest, SharedAgentToolEnvironment, +}; +use agent_client_protocol::schema::v1::{ + AgentCapabilities, CancelNotification, ContentBlock, ContentChunk, Implementation, + InitializeRequest, InitializeResponse, McpServer, NewSessionRequest, NewSessionResponse, + PromptCapabilities, PromptRequest, PromptResponse, SessionNotification, SessionUpdate, + StopReason, TextContent, +}; +use agent_client_protocol::util::MatchDispatchFrom; +use agent_client_protocol::{ + Agent as AcpAgent, ByteStreams, Client, ConnectionTo, Dispatch, HandleDispatchFrom, Handled, + JsonRpcNotification, Responder, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::{Mutex, RwLock, Semaphore}; +use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; +use tokio_util::sync::CancellationToken; + +#[cfg(target_os = "linux")] +use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _}; +#[cfg(unix)] +use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _}; +#[cfg(unix)] +use tokio::net::{UnixListener, UnixStream}; + +const ACP_PROTOCOL_VERSION: u16 = 1; +const MAX_ACP_CONNECTIONS: usize = 8; +const MAX_ACP_ERROR_CHARS: usize = 500; +const MAX_ACP_FRAME_BYTES: usize = 10 * 1024 * 1024; +const ACP_CONNECTION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const BRIDGE_HELLO_METHOD: &str = "_maple/bridge/hello"; +const ALLOWED_BRIDGE_ENV: [&str; 6] = [ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_DISPLAY_NAME", + "PATH", +]; + +#[cfg(unix)] +struct BoundedLineReader { + inner: R, + bytes_since_newline: usize, + eof: CancellationToken, +} + +#[cfg(unix)] +impl BoundedLineReader { + fn new(inner: R, eof: CancellationToken) -> Self { + Self { + inner, + bytes_since_newline: 0, + eof, + } + } +} + +#[cfg(unix)] +impl tokio::io::AsyncRead for BoundedLineReader { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let previous_len = buf.filled().len(); + match std::pin::Pin::new(&mut self.inner).poll_read(cx, buf) { + std::task::Poll::Ready(Ok(())) => { + if buf.filled().len() == previous_len { + self.eof.cancel(); + } + for byte in &buf.filled()[previous_len..] { + if *byte == b'\n' { + self.bytes_since_newline = 0; + } else { + self.bytes_since_newline = self.bytes_since_newline.saturating_add(1); + if self.bytes_since_newline > MAX_ACP_FRAME_BYTES { + return std::task::Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "ACP frame exceeds the 10 MiB limit", + ))); + } + } + } + std::task::Poll::Ready(Ok(())) + } + other => other, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentAcpPermissionMode { + ReadOnly, + AllowAll, +} + +impl AgentAcpPermissionMode { + fn maple_mode(&self) -> &'static str { + match self { + Self::ReadOnly => "smart_approve", + Self::AllowAll => "auto", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentAcpConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_permission_mode")] + pub permission_mode: AgentAcpPermissionMode, + #[serde(default)] + pub allowed_project_roots: Vec, + #[serde(default = "default_max_connections")] + pub max_connections: usize, +} + +fn default_permission_mode() -> AgentAcpPermissionMode { + AgentAcpPermissionMode::ReadOnly +} + +fn default_max_connections() -> usize { + 1 +} + +impl Default for AgentAcpConfig { + fn default() -> Self { + Self { + enabled: false, + permission_mode: default_permission_mode(), + allowed_project_roots: Vec::new(), + max_connections: default_max_connections(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAcpHarness { + pub command: String, + pub args: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAcpStatus { + pub running: bool, + pub enabled: bool, + pub connected_clients: usize, + pub active_sessions: usize, + pub active_runs: usize, + pub endpoint: Option, + pub endpoint_kind: Option, + pub protocol_version: u16, + pub error: Option, + pub buzz_credentials_available: bool, + pub harness: AgentAcpHarness, +} + +#[derive(Default)] +struct AgentAcpStats { + running: AtomicBool, + connected_clients: AtomicUsize, + active_sessions: AtomicUsize, + active_runs: AtomicUsize, + credential_connections: AtomicUsize, + last_error: Mutex>, +} + +struct RunningAgentAcp { + user_id: String, + endpoint: PathBuf, + config: Arc>, + stats: Arc, + cancellation: CancellationToken, + task: tauri::async_runtime::JoinHandle<()>, +} + +pub struct AgentAcpState { + lifecycle: Mutex<()>, + running: Mutex>, +} + +impl AgentAcpState { + pub fn new() -> Self { + Self { + lifecycle: Mutex::new(()), + running: Mutex::new(None), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[notification(method = "_maple/bridge/hello")] +struct BridgeHelloNotification { + environment: HashMap, +} + +struct AcpConnectionContext { + app_handle: AppHandle, + user_id: String, + config: Arc>, + stats: Arc, + bridge_environment: Mutex>, + sessions: Mutex>>, + prompt_states: Mutex>, + background_tasks: Mutex>, + finalization: Mutex<()>, + closed: AtomicBool, + has_credentials: AtomicBool, +} + +enum AcpPromptState { + Starting { cancel_requested: bool }, + Running { run_id: String }, +} + +impl AcpConnectionContext { + fn new( + app_handle: AppHandle, + user_id: String, + config: Arc>, + stats: Arc, + ) -> Arc { + Arc::new(Self { + app_handle, + user_id, + config, + stats, + bridge_environment: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + prompt_states: Mutex::new(HashMap::new()), + background_tasks: Mutex::new(tokio::task::JoinSet::new()), + finalization: Mutex::new(()), + closed: AtomicBool::new(false), + has_credentials: AtomicBool::new(false), + }) + } + + async fn set_bridge_environment(&self, environment: HashMap) { + let _finalization = self.finalization.lock().await; + if self.closed.load(Ordering::SeqCst) { + return; + } + let environment = filter_bridge_environment(environment); + let has_credentials = has_buzz_credentials(&environment); + *self.bridge_environment.lock().await = environment; + if has_credentials && !self.has_credentials.swap(true, Ordering::SeqCst) { + self.stats + .credential_connections + .fetch_add(1, Ordering::SeqCst); + } + } + + async fn new_session( + &self, + request: NewSessionRequest, + ) -> Result { + if self.closed.load(Ordering::SeqCst) { + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection is closing")); + } + if !request.cwd.is_absolute() { + return Err(agent_client_protocol::Error::invalid_params() + .data("ACP session cwd must be an absolute path")); + } + let config = self.config.read().await.clone(); + ensure_allowed_project_root(&request.cwd, &config.allowed_project_roots) + .map_err(|error| agent_client_protocol::Error::invalid_params().data(error))?; + + let mut environment = self.bridge_environment.lock().await.clone(); + merge_mcp_environment(&mut environment, &request.mcp_servers)?; + let mode = config.permission_mode.maple_mode().to_string(); + let detail = create_agent_session_for_user( + &self.app_handle, + self.user_id.clone(), + Some(AgentCreateSessionRequest { + project_root: Some(request.cwd.to_string_lossy().into_owned()), + title: Some("Buzz ACP".to_string()), + model: None, + context_limit: None, + mode: Some(mode), + mcp_server_names: None, + }), + ) + .await + .map_err(internal_acp_error)?; + let session_id = detail.session.id; + if self + .sessions + .lock() + .await + .insert(session_id.clone(), None) + .is_none() + { + self.stats.active_sessions.fetch_add(1, Ordering::SeqCst); + } + if self.closed.load(Ordering::SeqCst) { + self.discard_uncommitted_session(&session_id).await; + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection closed while creating the session")); + } + let tool_environment = match set_agent_session_tool_environment( + &self.app_handle, + &self.user_id, + &session_id, + environment.clone(), + ) + .await + { + Ok(tool_environment) => tool_environment, + Err(error) => { + self.discard_uncommitted_session(&session_id).await; + return Err(internal_acp_error(error)); + } + }; + let finalization = self.finalization.lock().await; + if self.closed.load(Ordering::SeqCst) { + tool_environment.write().await.clear(); + drop(finalization); + self.discard_uncommitted_session(&session_id).await; + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection closed while configuring the session")); + } + let environment_slot = self.sessions.lock().await.get_mut(&session_id).map(|slot| { + *slot = Some(tool_environment); + }); + if environment_slot.is_none() { + drop(finalization); + self.discard_uncommitted_session(&session_id).await; + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection lost its new session")); + } + if has_buzz_credentials(&environment) && !self.has_credentials.swap(true, Ordering::SeqCst) + { + self.stats + .credential_connections + .fetch_add(1, Ordering::SeqCst); + } + drop(finalization); + Ok(NewSessionResponse::new(session_id)) + } + + async fn discard_uncommitted_session(&self, session_id: &str) { + let _ = + clear_agent_session_tool_environment(&self.app_handle, &self.user_id, session_id).await; + let _ = delete_agent_session_for_user( + &self.app_handle, + self.user_id.clone(), + session_id.to_string(), + ) + .await; + if self.sessions.lock().await.remove(session_id).is_some() { + self.stats.active_sessions.fetch_sub(1, Ordering::SeqCst); + } + } + + async fn begin_prompt( + &self, + request: &PromptRequest, + ) -> Result { + if self.closed.load(Ordering::SeqCst) { + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection is closing")); + } + let session_id = request.session_id.0.to_string(); + if !self.sessions.lock().await.contains_key(&session_id) { + return Err( + agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) + .data("ACP session is not owned by this connection"), + ); + } + let prompt = prompt_text(&request.prompt)?; + let mut states = self.prompt_states.lock().await; + if states.contains_key(&session_id) { + return Err(agent_client_protocol::Error::invalid_request() + .data("This ACP session already has an active prompt")); + } + states.insert( + session_id, + AcpPromptState::Starting { + cancel_requested: false, + }, + ); + Ok(prompt) + } + + async fn prompt( + &self, + cx: &ConnectionTo, + request: PromptRequest, + prompt: String, + ) -> Result { + let session_id = request.session_id.0.to_string(); + let config = self.config.read().await.clone(); + let mut events = subscribe_agent_events(&self.app_handle); + let run = match send_agent_message_for_user( + &self.app_handle, + self.user_id.clone(), + AgentSendMessageRequest { + session_id: session_id.clone(), + text: prompt, + model: None, + context_limit: None, + mode: Some(config.permission_mode.maple_mode().to_string()), + vision_capable: false, + }, + ) + .await + { + Ok(run) => run, + Err(error) => { + self.prompt_states.lock().await.remove(&session_id); + return Err(internal_acp_error(error)); + } + }; + let run_id = run.run_id; + let mut terminal = run.terminal; + let cancel_requested = { + let mut states = self.prompt_states.lock().await; + match states.get_mut(&session_id) { + Some(state @ AcpPromptState::Starting { .. }) => { + let requested = match state { + AcpPromptState::Starting { cancel_requested } => *cancel_requested, + AcpPromptState::Running { .. } => unreachable!(), + }; + *state = AcpPromptState::Running { + run_id: run_id.clone(), + }; + Some(requested) + } + _ => None, + } + }; + let Some(cancel_requested) = cancel_requested else { + let _ = + cancel_agent_run_for_user(&self.app_handle, self.user_id.clone(), run_id.clone()) + .await; + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection closed while starting the prompt")); + }; + self.stats.active_runs.fetch_add(1, Ordering::SeqCst); + if cancel_requested { + // A cancellation failure does not make the active Maple run + // disappear. Keep listening so its lifecycle remains tracked. + let _ = + cancel_agent_run_for_user(&self.app_handle, self.user_id.clone(), run_id.clone()) + .await; + } + + let mut observed_terminal = None; + let mut event_stream_lagged = false; + let result = loop { + tokio::select! { + event = events.recv() => match event { + Ok(event) + if event.session_id.as_deref() == Some(session_id.as_str()) + && event.run_id.as_deref() == Some(run_id.as_str()) => + { + if event.event_type == "timelineItem" { + if let Some(update) = timeline_update(&event) { + if let Err(error) = cx.send_notification(SessionNotification::new( + request.session_id.clone(), + update, + )) { + break Err(error); + } + } + } else if event.event_type == "error" { + if let Some(message) = event_error_text(&event) { + if let Err(error) = cx.send_notification(SessionNotification::new( + request.session_id.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::Text(TextContent::new(message)), + )), + )) { + break Err(error); + } + } + } else if event.event_type == "runFinished" { + let terminal = match event.message.as_deref() { + Some("cancelled") => AgentRunTerminal::Cancelled, + Some("failed") => AgentRunTerminal::Failed, + _ => AgentRunTerminal::Completed, + }; + break prompt_result_from_terminal(terminal); + } + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // Once any frames were lost, the retained per-run signal + // becomes authoritative. It cannot be overwritten by + // unrelated Maple UI traffic. + event_stream_lagged = true; + if let Some(terminal) = observed_terminal { + break prompt_result_from_terminal(terminal); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + if let Some(terminal) = observed_terminal.or_else(|| *terminal.borrow()) { + break prompt_result_from_terminal(terminal); + } + break Err(agent_client_protocol::Error::internal_error() + .data("Maple Agent event stream closed")); + } + }, + changed = terminal.changed(), if observed_terminal.is_none() => { + match changed { + Ok(()) => { + observed_terminal = *terminal.borrow_and_update(); + if event_stream_lagged { + if let Some(terminal) = observed_terminal { + break prompt_result_from_terminal(terminal); + } + } + // In the ordinary path runFinished was broadcast + // before this signal. Keep draining the ordered event + // stream so Buzz receives every available text chunk. + } + Err(_) => { + if let Some(terminal) = *terminal.borrow() { + observed_terminal = Some(terminal); + if event_stream_lagged { + break prompt_result_from_terminal(terminal); + } + } else { + break Err(agent_client_protocol::Error::internal_error() + .data("Maple Agent run ended without a terminal result")); + } + } + } + } + } + }; + if result.is_err() { + // If the ACP client disappears while Maple is still producing a + // turn, stop the underlying run before removing it from this + // connection's cleanup map. A completed/failed run simply makes + // this best-effort cancellation a no-op. + let _ = cancel_agent_run_for_user(&self.app_handle, self.user_id.clone(), run_id).await; + } + if matches!( + self.prompt_states.lock().await.remove(&session_id), + Some(AcpPromptState::Running { .. }) + ) { + self.stats.active_runs.fetch_sub(1, Ordering::SeqCst); + } + result + } + + async fn cancel( + &self, + notification: CancelNotification, + ) -> Result<(), agent_client_protocol::Error> { + let session_id = notification.session_id.0.to_string(); + let run_id = { + let mut states = self.prompt_states.lock().await; + match states.get_mut(&session_id) { + Some(AcpPromptState::Starting { cancel_requested }) => { + *cancel_requested = true; + None + } + Some(AcpPromptState::Running { run_id }) => Some(run_id.clone()), + None => None, + } + }; + if let Some(run_id) = run_id { + cancel_agent_run_for_user(&self.app_handle, self.user_id.clone(), run_id) + .await + .map_err(internal_acp_error)?; + } + Ok(()) + } + + async fn cleanup(&self) { + { + // Linearize closure with the last new-session credential commit. + // A task that reaches finalization after this point observes closed + // and rolls its newly persisted session back instead of committing. + let _finalization = self.finalization.lock().await; + self.closed.store(true, Ordering::SeqCst); + self.bridge_environment.lock().await.clear(); + if self.has_credentials.swap(false, Ordering::SeqCst) { + self.stats + .credential_connections + .fetch_sub(1, Ordering::SeqCst); + } + } + let prompt_states = std::mem::take(&mut *self.prompt_states.lock().await); + for state in prompt_states.into_values() { + if let AcpPromptState::Running { run_id } = state { + let _ = + cancel_agent_run_for_user(&self.app_handle, self.user_id.clone(), run_id).await; + self.stats.active_runs.fetch_sub(1, Ordering::SeqCst); + } + } + let sessions = std::mem::take(&mut *self.sessions.lock().await); + for environment in sessions.values().flatten() { + // Clear the exact Arc installed in Maple's developer client. This + // revokes Buzz secrets without waiting behind runtime setup locks. + environment.write().await.clear(); + } + self.stats + .active_sessions + .fetch_sub(sessions.len(), Ordering::SeqCst); + let mut tasks = self.background_tasks.lock().await; + let deadline = tokio::time::Instant::now() + ACP_CONNECTION_CLEANUP_TIMEOUT; + loop { + match tokio::time::timeout_at(deadline, tasks.join_next()).await { + Ok(Some(_)) => {} + Ok(None) => break, + Err(_) => { + // Session creation and the pre-run prompt path both cross + // persistent core state before returning an ID. Aborting + // them here could orphan that state. Detaching preserves + // their existing closed checks and rollback/cancel paths + // while keeping connection shutdown bounded. + tasks.detach_all(); + break; + } + } + } + drop(tasks); + } +} + +#[derive(Clone)] +struct MapleAcpHandler { + context: Arc, +} + +impl HandleDispatchFrom for MapleAcpHandler { + fn describe_chain(&self) -> impl std::fmt::Debug { + "maple-acp" + } + + fn handle_dispatch_from( + &mut self, + message: Dispatch, + cx: ConnectionTo, + ) -> impl std::future::Future, agent_client_protocol::Error>> + Send + { + let context = Arc::clone(&self.context); + Box::pin(async move { + MatchDispatchFrom::new(message, &cx) + .if_notification({ + let context = Arc::clone(&context); + |notification: BridgeHelloNotification| async move { + context.set_bridge_environment(notification.environment).await; + Ok(()) + } + }) + .await + .if_request( + |_request: InitializeRequest, responder: Responder| async { + let capabilities = AgentCapabilities::new().prompt_capabilities( + PromptCapabilities::new() + .image(false) + .audio(false) + .embedded_context(false), + ); + responder.respond( + InitializeResponse::new( + agent_client_protocol::schema::ProtocolVersion::V1, + ) + .agent_info(Implementation::new("maple", env!("CARGO_PKG_VERSION"))) + .agent_capabilities(capabilities), + ) + }, + ) + .await + .if_request({ + let context = Arc::clone(&context); + |request: NewSessionRequest, responder: Responder| async move { + let task_context = Arc::clone(&context); + let mut tasks = context.background_tasks.lock().await; + while tasks.try_join_next().is_some() {} + if context.closed.load(Ordering::SeqCst) { + responder.respond_with_error( + agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection is closing"), + )?; + return Ok(()); + } + tasks.spawn(async move { + let _ = responder + .respond_with_result(task_context.new_session(request).await); + }); + Ok(()) + } + }) + .await + .if_request({ + let context = Arc::clone(&context); + let cx = cx.clone(); + |request: PromptRequest, responder: Responder| async move { + let prompt = context.begin_prompt(&request).await?; + let prompt_cx = cx.clone(); + let prompt_context = Arc::clone(&context); + let mut tasks = context.background_tasks.lock().await; + while tasks.try_join_next().is_some() {} + if context.closed.load(Ordering::SeqCst) { + context.prompt_states.lock().await.remove( + &request.session_id.0.to_string(), + ); + responder.respond_with_error( + agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection is closing"), + )?; + return Ok(()); + } + tasks.spawn(async move { + let _ = responder.respond_with_result( + prompt_context.prompt(&prompt_cx, request, prompt).await, + ); + }); + Ok(()) + } + }) + .await + .if_notification({ + let context = Arc::clone(&context); + |notification: CancelNotification| async move { + context.cancel(notification).await + } + }) + .await + .otherwise({ + let cx = cx.clone(); + |message: Dispatch| async move { + match message { + Dispatch::Request(_, responder) => { + responder.respond_with_error( + agent_client_protocol::Error::method_not_found(), + )?; + } + Dispatch::Response(result, router) => { + router.respond_with_result(result)?; + } + Dispatch::Notification(_) => {} + } + let _ = cx; + Ok(()) + } + }) + .await + .map(|()| Handled::Yes) + }) + } +} + +#[tauri::command] +pub async fn agent_acp_load_config( + app_handle: AppHandle, + user_id: String, +) -> Result { + load_config(&app_handle, &user_id) +} + +#[tauri::command] +pub async fn agent_acp_save_config( + app_handle: AppHandle, + user_id: String, + config: AgentAcpConfig, +) -> Result { + let config = normalize_config(config)?; + let state = app_handle.state::(); + let running = state.running.lock().await; + if let Some(running) = running.as_ref() { + if running.user_id == user_id { + let current = running.config.read().await.clone(); + if current.permission_mode != config.permission_mode + && running.stats.running.load(Ordering::SeqCst) + { + return Err( + "Stop the ACP service before changing the permission policy".to_string() + ); + } + } + } + save_config(&app_handle, &user_id, &config)?; + if let Some(running) = running.as_ref() { + if running.user_id == user_id { + *running.config.write().await = config.clone(); + } + } + Ok(config) +} + +#[tauri::command] +pub async fn agent_acp_start( + app_handle: AppHandle, + user_id: String, +) -> Result { + start_service(&app_handle, &user_id).await?; + status(&app_handle, &user_id).await +} + +#[tauri::command] +pub async fn agent_acp_stop( + app_handle: AppHandle, + user_id: String, +) -> Result { + stop_service(&app_handle, Some(&user_id), true).await?; + status(&app_handle, &user_id).await +} + +#[tauri::command] +pub async fn agent_acp_get_status( + app_handle: AppHandle, + user_id: String, +) -> Result { + status(&app_handle, &user_id).await +} + +pub async fn shutdown_agent_acp(app_handle: &AppHandle) -> Result<(), String> { + stop_service(app_handle, None, false).await +} + +#[cfg(unix)] +async fn start_service(app_handle: &AppHandle, user_id: &str) -> Result<(), String> { + if user_id.trim().is_empty() { + return Err("Cannot start ACP without an authenticated Maple user".to_string()); + } + let state = app_handle.state::(); + let _guard = state.lifecycle.lock().await; + let stale = { + let mut slot = state.running.lock().await; + match slot.as_ref() { + Some(running) if running.stats.running.load(Ordering::SeqCst) => { + if running.user_id == user_id { + return Ok(()); + } + return Err("ACP is already running for another Maple account".to_string()); + } + Some(_) => slot.take(), + None => None, + } + }; + if let Some(stale) = stale { + stale.cancellation.cancel(); + let _ = stale.task.await; + remove_socket_if_present(&stale.endpoint)?; + } + ensure_agent_runtime_for_user(app_handle, user_id.to_string(), None).await?; + let mut config = normalize_config(load_config(app_handle, user_id)?)?; + config.enabled = true; + + let (listener, endpoint) = bind_listener()?; + + save_config(app_handle, user_id, &config)?; + let config = Arc::new(RwLock::new(config)); + let stats = Arc::new(AgentAcpStats::default()); + stats.running.store(true, Ordering::SeqCst); + let cancellation = CancellationToken::new(); + let task = tauri::async_runtime::spawn(run_listener( + listener, + endpoint.clone(), + app_handle.clone(), + user_id.to_string(), + Arc::clone(&config), + Arc::clone(&stats), + cancellation.clone(), + )); + *state.running.lock().await = Some(RunningAgentAcp { + user_id: user_id.to_string(), + endpoint, + config, + stats, + cancellation, + task, + }); + Ok(()) +} + +#[cfg(not(unix))] +async fn start_service(_app_handle: &AppHandle, _user_id: &str) -> Result<(), String> { + Err("Maple ACP local IPC is not yet supported on this platform".to_string()) +} + +async fn stop_service( + app_handle: &AppHandle, + requested_user: Option<&str>, + persist_disabled: bool, +) -> Result<(), String> { + let state = app_handle.state::(); + let _guard = state.lifecycle.lock().await; + let running = { + let mut slot = state.running.lock().await; + if let (Some(requested), Some(running)) = (requested_user, slot.as_ref()) { + if running.user_id != requested { + return Err("ACP belongs to another Maple account".to_string()); + } + } + slot.take() + }; + let Some(running) = running else { + if persist_disabled { + if let Some(user_id) = requested_user { + let mut config = load_config(app_handle, user_id)?; + config.enabled = false; + save_config(app_handle, user_id, &config)?; + } + } + return Ok(()); + }; + running.cancellation.cancel(); + let _ = running.task.await; + remove_socket_if_present(&running.endpoint)?; + if persist_disabled { + let mut config = running.config.read().await.clone(); + config.enabled = false; + save_config(app_handle, &running.user_id, &config)?; + } + Ok(()) +} + +async fn status(app_handle: &AppHandle, user_id: &str) -> Result { + let state = app_handle.state::(); + let running = state.running.lock().await; + let harness = harness()?; + if let Some(running) = running.as_ref() { + if running.user_id != user_id { + return Err("ACP belongs to another Maple account".to_string()); + } + let config = running.config.read().await.clone(); + return Ok(AgentAcpStatus { + running: running.stats.running.load(Ordering::SeqCst), + enabled: config.enabled, + connected_clients: running.stats.connected_clients.load(Ordering::SeqCst), + active_sessions: running.stats.active_sessions.load(Ordering::SeqCst), + active_runs: running.stats.active_runs.load(Ordering::SeqCst), + endpoint: Some(running.endpoint.to_string_lossy().into_owned()), + endpoint_kind: Some("unix_socket".to_string()), + protocol_version: ACP_PROTOCOL_VERSION, + error: running.stats.last_error.lock().await.clone(), + buzz_credentials_available: running.stats.credential_connections.load(Ordering::SeqCst) + > 0, + harness, + }); + } + drop(running); + let config = load_config(app_handle, user_id)?; + Ok(AgentAcpStatus { + running: false, + enabled: config.enabled, + connected_clients: 0, + active_sessions: 0, + active_runs: 0, + endpoint: endpoint_path() + .ok() + .map(|path| path.to_string_lossy().into_owned()), + endpoint_kind: cfg!(unix).then(|| "unix_socket".to_string()), + protocol_version: ACP_PROTOCOL_VERSION, + error: None, + buzz_credentials_available: false, + harness, + }) +} + +#[cfg(unix)] +async fn run_listener( + listener: UnixListener, + endpoint: PathBuf, + app_handle: AppHandle, + user_id: String, + config: Arc>, + stats: Arc, + cancellation: CancellationToken, +) { + let limit = Arc::new(Semaphore::new(config.read().await.max_connections)); + let mut connections = tokio::task::JoinSet::new(); + loop { + tokio::select! { + _ = cancellation.cancelled() => break, + completed = connections.join_next(), if !connections.is_empty() => { + if let Some(Err(error)) = completed { + *stats.last_error.lock().await = Some(bounded_error(&error.to_string())); + } + } + accepted = listener.accept() => match accepted { + Ok((stream, _)) => { + let Ok(permit) = Arc::clone(&limit).try_acquire_owned() else { + drop(stream); + continue; + }; + let app_handle = app_handle.clone(); + let user_id = user_id.clone(); + let config = Arc::clone(&config); + let stats = Arc::clone(&stats); + let connection_cancel = cancellation.clone(); + connections.spawn(async move { + let _permit = permit; + stats.connected_clients.fetch_add(1, Ordering::SeqCst); + let context = AcpConnectionContext::new( + app_handle, + user_id, + config, + Arc::clone(&stats), + ); + let (read, write) = stream.into_split(); + let peer_eof = CancellationToken::new(); + let read = BoundedLineReader::new(read, peer_eof.clone()); + let serving = AcpAgent + .builder() + .name("maple-acp") + .with_handler(MapleAcpHandler { + context: Arc::clone(&context), + }) + .connect_to(ByteStreams::new(write.compat_write(), read.compat())); + tokio::select! { + result = serving => { + if let Err(error) = result { + *stats.last_error.lock().await = Some(bounded_error(&error.to_string())); + } + } + _ = connection_cancel.cancelled() => {} + _ = peer_eof.cancelled() => {} + } + context.cleanup().await; + stats.connected_clients.fetch_sub(1, Ordering::SeqCst); + }); + } + Err(error) => { + *stats.last_error.lock().await = Some(bounded_error(&error.to_string())); + break; + } + } + } + } + cancellation.cancel(); + while connections.join_next().await.is_some() {} + stats.running.store(false, Ordering::SeqCst); + let _ = remove_socket_if_present(&endpoint); +} + +#[cfg(unix)] +fn bind_listener() -> Result<(UnixListener, PathBuf), String> { + let endpoint = endpoint_path()?; + if let Ok(metadata) = std::fs::symlink_metadata(&endpoint) { + if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() { + return Err(format!( + "Refusing to replace unexpected ACP endpoint {}", + endpoint.display() + )); + } + if std::os::unix::net::UnixStream::connect(&endpoint).is_ok() { + return Err("Another Maple ACP service is already listening".to_string()); + } + std::fs::remove_file(&endpoint) + .map_err(|error| format!("Failed to remove stale ACP endpoint: {error}"))?; + } + let listener = UnixListener::bind(&endpoint) + .map_err(|error| format!("Failed to bind Maple ACP endpoint: {error}"))?; + std::fs::set_permissions(&endpoint, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("Failed to secure Maple ACP endpoint: {error}"))?; + Ok((listener, endpoint)) +} + +fn endpoint_path() -> Result { + let executable = stable_executable_path()?; + let digest = Sha256::digest(executable.to_string_lossy().as_bytes()); + let suffix = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(endpoint_root()?.join(format!("maple-acp-{suffix}.sock"))) +} + +#[cfg(target_os = "linux")] +fn endpoint_root() -> Result { + let current_uid = std::fs::metadata("/proc/self") + .map_err(|error| format!("Failed to resolve the current Linux user: {error}"))? + .uid(); + let root = std::env::var_os("XDG_RUNTIME_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join(format!("maple-acp-{current_uid}"))); + match std::fs::symlink_metadata(&root) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(&root).map_err(|error| { + format!("Failed to create the Maple ACP runtime directory: {error}") + })?; + } + Err(error) => { + return Err(format!( + "Failed to inspect the Maple ACP runtime directory: {error}" + )); + } + } + let metadata = std::fs::symlink_metadata(&root) + .map_err(|error| format!("Failed to inspect the Maple ACP runtime directory: {error}"))?; + if metadata.file_type().is_symlink() + || !metadata.file_type().is_dir() + || metadata.uid() != current_uid + { + return Err("Maple ACP runtime directory is not owned by the current user".to_string()); + } + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("Failed to secure the Maple ACP runtime directory: {error}"))?; + Ok(root) +} + +#[cfg(not(target_os = "linux"))] +fn endpoint_root() -> Result { + Ok(std::env::temp_dir()) +} + +fn stable_executable_path() -> Result { + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return Ok(PathBuf::from(appimage)); + } + let executable = std::env::current_exe() + .map_err(|error| format!("Failed to find the Maple executable: {error}"))?; + Ok(executable.canonicalize().unwrap_or(executable)) +} + +fn harness() -> Result { + Ok(AgentAcpHarness { + command: stable_executable_path()?.to_string_lossy().into_owned(), + args: vec!["acp".to_string()], + }) +} + +fn config_path(app_handle: &AppHandle, user_id: &str) -> Result { + if user_id.trim().is_empty() { + return Err("Maple ACP configuration requires an authenticated user".to_string()); + } + let digest = Sha256::digest(user_id.as_bytes()); + let scope = digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let root = app_handle + .path() + .app_local_data_dir() + .map_err(|error| format!("Failed to resolve Maple local data: {error}"))?; + Ok(root + .join("acp") + .join("accounts") + .join(scope) + .join("config.json")) +} + +fn load_config(app_handle: &AppHandle, user_id: &str) -> Result { + let path = config_path(app_handle, user_id)?; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("Failed to parse Maple ACP configuration: {error}")) + .and_then(normalize_config), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AgentAcpConfig::default()), + Err(error) => Err(format!("Failed to read Maple ACP configuration: {error}")), + } +} + +fn save_config( + app_handle: &AppHandle, + user_id: &str, + config: &AgentAcpConfig, +) -> Result<(), String> { + let path = config_path(app_handle, user_id)?; + let parent = path + .parent() + .ok_or_else(|| "Invalid Maple ACP configuration path".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create Maple ACP configuration directory: {error}"))?; + #[cfg(unix)] + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("Failed to secure Maple ACP configuration directory: {error}"))?; + let bytes = serde_json::to_vec_pretty(config) + .map_err(|error| format!("Failed to encode Maple ACP configuration: {error}"))?; + std::fs::write(&path, bytes) + .map_err(|error| format!("Failed to save Maple ACP configuration: {error}"))?; + #[cfg(unix)] + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("Failed to secure Maple ACP configuration: {error}"))?; + Ok(()) +} + +pub(crate) fn clear_agent_acp_config(app_handle: &AppHandle, user_id: &str) -> Result<(), String> { + let path = config_path(app_handle, user_id)?; + let account_dir = path + .parent() + .ok_or_else(|| "Invalid Maple ACP configuration path".to_string())?; + match std::fs::remove_dir_all(account_dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("Failed to clear Maple ACP configuration: {error}")), + } +} + +fn normalize_config(mut config: AgentAcpConfig) -> Result { + config.max_connections = config.max_connections.clamp(1, MAX_ACP_CONNECTIONS); + let mut roots = Vec::new(); + for root in config.allowed_project_roots { + let root = root.trim(); + if root.is_empty() { + continue; + } + let path = PathBuf::from(root); + if !path.is_absolute() { + return Err("ACP allowed project roots must be absolute paths".to_string()); + } + let root = path.to_string_lossy().into_owned(); + if !roots.contains(&root) { + roots.push(root); + } + } + config.allowed_project_roots = roots; + Ok(config) +} + +fn ensure_allowed_project_root(cwd: &Path, allowed_roots: &[String]) -> Result<(), String> { + if allowed_roots.is_empty() { + return Ok(()); + } + let cwd = cwd + .canonicalize() + .map_err(|error| format!("Failed to resolve ACP session cwd: {error}"))?; + for root in allowed_roots { + if let Ok(root) = Path::new(root).canonicalize() { + if cwd.starts_with(root) { + return Ok(()); + } + } + } + Err("ACP session cwd is outside the configured project roots".to_string()) +} + +fn merge_mcp_environment( + environment: &mut HashMap, + servers: &[McpServer], +) -> Result<(), agent_client_protocol::Error> { + for server in servers { + let McpServer::Stdio(server) = server else { + return Err(agent_client_protocol::Error::invalid_params() + .data("Maple ACP currently supports only stdio MCP session definitions")); + }; + let command = Path::new(&server.command); + let is_buzz_dev_mcp = server.name == "buzz-dev-mcp" + && command + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "buzz-dev-mcp" || name == "buzz-dev-mcp.exe") + && server.args.is_empty(); + if !is_buzz_dev_mcp || !command.is_absolute() { + return Err(agent_client_protocol::Error::invalid_params().data( + "Maple ACP currently adapts only Buzz's absolute buzz-dev-mcp stdio definition", + )); + } + let metadata = std::fs::metadata(command).map_err(|_| { + agent_client_protocol::Error::invalid_params() + .data("Buzz buzz-dev-mcp command does not exist") + })?; + if !metadata.is_file() { + return Err(agent_client_protocol::Error::invalid_params() + .data("Buzz buzz-dev-mcp command is not a file")); + } + #[cfg(unix)] + if metadata.permissions().mode() & 0o111 == 0 { + return Err(agent_client_protocol::Error::invalid_params() + .data("Buzz buzz-dev-mcp command is not executable")); + } + // Maple already exposes a tightly controlled shell tool. For Buzz's + // dev MCP, adapt the exact child environment into that per-session + // shell rather than launching a second general-purpose shell server. + for variable in &server.env { + if !ALLOWED_BRIDGE_ENV.contains(&variable.name.as_str()) { + continue; + } + if let Some(existing) = environment.get(&variable.name) { + if existing != &variable.value { + return Err(agent_client_protocol::Error::invalid_params().data(format!( + "Conflicting ACP environment value for {}", + variable.name + ))); + } + } else { + environment.insert(variable.name.clone(), variable.value.clone()); + } + } + } + Ok(()) +} + +fn filter_bridge_environment(environment: HashMap) -> HashMap { + environment + .into_iter() + .filter(|(key, value)| { + ALLOWED_BRIDGE_ENV.contains(&key.as_str()) + && !value.contains('\0') + && value.len() <= 16 * 1024 + }) + .collect() +} + +fn has_buzz_credentials(environment: &HashMap) -> bool { + environment + .get("BUZZ_RELAY_URL") + .is_some_and(|value| !value.is_empty()) + && environment + .get("BUZZ_PRIVATE_KEY") + .is_some_and(|value| !value.is_empty()) +} + +fn prompt_text(blocks: &[ContentBlock]) -> Result { + let text = blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"); + if text.trim().is_empty() { + return Err(agent_client_protocol::Error::invalid_params() + .data("Maple ACP requires at least one text prompt block")); + } + Ok(text) +} + +fn timeline_update(event: &AgentEventEnvelope) -> Option { + let item = event.item.as_ref()?; + let text = item.text.as_deref()?.to_string(); + match item.item_type.as_str() { + "message" if item.role.as_deref() == Some("assistant") => { + Some(SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::Text(TextContent::new(text)), + ))) + } + "thinking" => Some(SessionUpdate::AgentThoughtChunk(ContentChunk::new( + ContentBlock::Text(TextContent::new(text)), + ))), + _ => None, + } +} + +fn event_error_text(event: &AgentEventEnvelope) -> Option { + event + .message + .clone() + .or_else(|| event.item.as_ref().and_then(|item| item.text.clone())) + .map(|message| bounded_error(&message)) +} + +fn prompt_result_from_terminal( + terminal: AgentRunTerminal, +) -> Result { + match terminal { + AgentRunTerminal::Completed => Ok(PromptResponse::new(StopReason::EndTurn)), + AgentRunTerminal::Cancelled => Ok(PromptResponse::new(StopReason::Cancelled)), + AgentRunTerminal::Failed => { + Err(agent_client_protocol::Error::internal_error().data("Maple Agent prompt failed")) + } + } +} + +fn internal_acp_error(error: String) -> agent_client_protocol::Error { + agent_client_protocol::Error::internal_error().data(bounded_error(&error)) +} + +fn bounded_error(error: &str) -> String { + error.chars().take(MAX_ACP_ERROR_CHARS).collect() +} + +fn remove_socket_if_present(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("Failed to remove Maple ACP endpoint: {error}")), + } +} + +#[cfg(target_os = "linux")] +fn verify_connector_endpoint(path: &Path) -> Result<(), String> { + let current_uid = std::fs::metadata("/proc/self") + .map_err(|error| format!("Failed to resolve the current Linux user: {error}"))? + .uid(); + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| format!("Maple Desktop ACP service is unavailable: {error}"))?; + if metadata.file_type().is_symlink() + || !metadata.file_type().is_socket() + || metadata.uid() != current_uid + || metadata.permissions().mode() & 0o077 != 0 + { + return Err("Refusing an insecure Maple ACP endpoint".to_string()); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn verify_connector_endpoint(_path: &Path) -> Result<(), String> { + Ok(()) +} + +pub fn run_acp_connector() -> Result<(), String> { + #[cfg(unix)] + { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("Failed to start Maple ACP connector: {error}"))?; + runtime.block_on(run_acp_connector_async()) + } + #[cfg(not(unix))] + Err("Maple ACP local IPC is not yet supported on this platform".to_string()) +} + +#[cfg(unix)] +async fn run_acp_connector_async() -> Result<(), String> { + use tokio::io::{copy, AsyncWriteExt as _}; + + let endpoint = endpoint_path()?; + verify_connector_endpoint(&endpoint)?; + let stream = UnixStream::connect(&endpoint).await.map_err(|error| { + format!( + "Maple Desktop ACP service is unavailable at {}: {error}", + endpoint.display() + ) + })?; + let (mut socket_read, mut socket_write) = stream.into_split(); + let environment = ALLOWED_BRIDGE_ENV + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect::>(); + let hello = serde_json::json!({ + "jsonrpc": "2.0", + "method": BRIDGE_HELLO_METHOD, + "params": { "environment": environment } + }); + let mut hello = serde_json::to_vec(&hello) + .map_err(|error| format!("Failed to encode Maple ACP bridge hello: {error}"))?; + hello.push(b'\n'); + socket_write + .write_all(&hello) + .await + .map_err(|error| format!("Failed to initialize Maple ACP bridge: {error}"))?; + socket_write + .flush() + .await + .map_err(|error| format!("Failed to flush Maple ACP bridge: {error}"))?; + + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let outbound = async { + copy(&mut stdin, &mut socket_write).await?; + socket_write.shutdown().await + }; + let inbound = async { + copy(&mut socket_read, &mut stdout).await?; + stdout.flush().await + }; + tokio::pin!(outbound); + tokio::pin!(inbound); + tokio::select! { + result = &mut inbound => { + // The desktop service closing the socket must terminate the + // Buzz-owned connector even while its stdin remains open. + result.map_err(|error| format!("Maple ACP bridge failed: {error}"))?; + } + result = &mut outbound => { + // When Buzz closes stdin, preserve the half-close behavior and + // keep relaying any final ACP response until Maple closes output. + result.map_err(|error| format!("Maple ACP bridge failed: {error}"))?; + inbound + .await + .map_err(|error| format!("Maple ACP bridge failed: {error}"))?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_is_disabled_and_read_only() { + let config = AgentAcpConfig::default(); + assert!(!config.enabled); + assert_eq!(config.permission_mode, AgentAcpPermissionMode::ReadOnly); + assert_eq!(config.max_connections, 1); + } + + #[test] + fn bridge_environment_is_strictly_allowlisted() { + let filtered = filter_bridge_environment(HashMap::from([ + ( + "BUZZ_RELAY_URL".to_string(), + "ws://localhost:3000".to_string(), + ), + ("UNRELATED_SECRET".to_string(), "nope".to_string()), + ])); + assert_eq!(filtered.len(), 1); + assert!(filtered.contains_key("BUZZ_RELAY_URL")); + } + + #[test] + fn prompt_blocks_preserve_buzz_order() { + let blocks = vec![ + ContentBlock::Text(TextContent::new("[Base]\nbase")), + ContentBlock::Text(TextContent::new("[System]\nsystem")), + ]; + assert_eq!( + prompt_text(&blocks).unwrap(), + "[Base]\nbase\n\n[System]\nsystem" + ); + } + + #[test] + fn retained_terminal_results_preserve_all_stop_states() { + let completed = prompt_result_from_terminal(AgentRunTerminal::Completed).unwrap(); + assert_eq!( + serde_json::to_value(completed).unwrap()["stopReason"], + "end_turn" + ); + + let cancelled = prompt_result_from_terminal(AgentRunTerminal::Cancelled).unwrap(); + assert_eq!( + serde_json::to_value(cancelled).unwrap()["stopReason"], + "cancelled" + ); + + assert!(prompt_result_from_terminal(AgentRunTerminal::Failed).is_err()); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 5ec9f6403..6f172f22e 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ use tauri_plugin_deep_link::DeepLinkExt; #[cfg(desktop)] mod agent; #[cfg(desktop)] +mod agent_acp; +#[cfg(desktop)] mod maple_api; mod onnxruntime; mod pdf_extractor; @@ -17,6 +19,7 @@ mod tts; #[tauri::command] async fn restart_for_update(app_handle: tauri::AppHandle) -> Result<(), String> { log::info!("User requested restart for update"); + agent_acp::shutdown_agent_acp(&app_handle).await?; agent::shutdown_agent_runtime(&app_handle).await?; app_handle.restart(); } @@ -52,6 +55,9 @@ fn handle_desktop_run_event(app_handle: &tauri::AppHandle, event: tauri::RunEven let app_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { + if let Err(error) = agent_acp::shutdown_agent_acp(&app_handle).await { + log::error!("Failed to stop ACP during app exit: {error}"); + } if let Err(error) = agent::shutdown_agent_runtime(&app_handle).await { log::error!("Failed to stop Agent Mode during app exit: {error}"); } @@ -86,6 +92,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .manage(agent::AgentRuntimeState::new()) + .manage(agent_acp::AgentAcpState::new()) .manage(maple_api::MapleApiAuthState::new()) .manage(proxy::ProxyState::new()) .manage(tts::TTSState::new()) @@ -116,6 +123,11 @@ pub fn run() { agent::agent_permission_respond, agent::agent_clear_user_history, agent::agent_clear_user_data, + agent_acp::agent_acp_load_config, + agent_acp::agent_acp_save_config, + agent_acp::agent_acp_start, + agent_acp::agent_acp_stop, + agent_acp::agent_acp_get_status, maple_api::maple_api_set_auth, maple_api::maple_api_get_auth, maple_api::maple_api_clear_auth, @@ -386,6 +398,11 @@ pub fn run() { .expect("error while running tauri application"); } +#[cfg(desktop)] +pub fn run_acp_connector() -> Result<(), String> { + agent_acp::run_acp_connector() +} + // Create a global variable to track if an update is already prepared and notified #[cfg(desktop)] use once_cell::sync::Lazy; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 69c3a72ec..0f6959237 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,5 +2,13 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + let mut args = std::env::args().skip(1); + if args.next().as_deref() == Some("acp") { + if let Err(error) = app_lib::run_acp_connector() { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } app_lib::run(); } diff --git a/frontend/src/components/settings/AgentConnectionsSettings.tsx b/frontend/src/components/settings/AgentConnectionsSettings.tsx new file mode 100644 index 000000000..985a17ead --- /dev/null +++ b/frontend/src/components/settings/AgentConnectionsSettings.tsx @@ -0,0 +1,770 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useOpenSecret } from "@opensecret/react"; +import { + AlertCircle, + Check, + Copy, + Loader2, + Play, + RefreshCw, + Server, + ShieldCheck, + Square, + Terminal +} from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext"; +import { awaitAgentAuthUser } from "@/services/agentRuntimeService"; +import { + BUZZ_DEFAULT_AGENT_PARALLELISM, + BUZZ_MAPLE_AGENT_PARALLELISM, + BUZZ_MAPLE_HARNESS_ID, + BUZZ_MAPLE_HARNESS_NAME, + isMapleAcpConfigReady, + isMapleAcpPolicyDirty, + mapleAcpService, + serializeBuzzCustomHarness, + type MapleAcpConfig, + type MapleAcpPermissionMode, + type MapleAcpStatus +} from "@/services/mapleAcpService"; +import { SettingsPage, SettingsSection } from "./SettingsPage"; + +const STATUS_POLL_INTERVAL_MS = 2_500; + +type CopyTarget = "name" | "id" | "command" | "argument" | "harness"; + +export function AgentConnectionsSettings() { + const os = useOpenSecret(); + const userId = os.auth.user?.user.id ?? null; + const [config, setConfig] = useState(null); + const [savedConfig, setSavedConfig] = useState(null); + const [configUserId, setConfigUserId] = useState(null); + const [status, setStatus] = useState(null); + const [isConfigLoading, setIsConfigLoading] = useState(true); + const [isStatusLoading, setIsStatusLoading] = useState(true); + const [operation, setOperation] = useState<"start" | "stop" | "save" | "refresh" | null>(null); + const [error, setError] = useState(null); + const [configLoadError, setConfigLoadError] = useState(null); + const [statusLoadError, setStatusLoadError] = useState(null); + const [message, setMessage] = useState(null); + const [copied, setCopied] = useState(null); + const userIdRef = useRef(userId); + userIdRef.current = userId; + const operationRef = useRef(operation); + operationRef.current = operation; + const statusRequestGenerationRef = useRef(0); + const isBusy = operation !== null; + useSettingsNavigationLock(isBusy); + + const refreshSettings = useCallback(async () => { + if (!userId) return; + + statusRequestGenerationRef.current += 1; + const shouldRetryConfig = savedConfig === null || configUserId !== userId; + setOperation("refresh"); + setError(null); + setMessage(null); + setIsStatusLoading(true); + setStatusLoadError(null); + if (shouldRetryConfig) { + setIsConfigLoading(true); + setConfigLoadError(null); + } + + const authReady = awaitAgentAuthUser(userId); + const configRequest = shouldRetryConfig + ? authReady + .then(() => mapleAcpService.loadConfig(userId)) + .then((nextConfig) => { + if (userIdRef.current !== userId) return false; + setConfig(nextConfig); + setSavedConfig(nextConfig); + setConfigUserId(userId); + setConfigLoadError(null); + return true; + }) + .catch((loadError) => { + if (userIdRef.current !== userId) return false; + setConfigLoadError(errorMessage(loadError, "Maple could not load the agent policy.")); + return false; + }) + .finally(() => { + if (userIdRef.current === userId) setIsConfigLoading(false); + }) + : Promise.resolve(true); + const statusRequest = authReady + .then(() => mapleAcpService.getStatus(userId)) + .then((nextStatus) => { + if (userIdRef.current !== userId) return false; + setStatus(nextStatus); + setStatusLoadError(null); + return true; + }) + .catch((loadError) => { + if (userIdRef.current !== userId) return false; + setStatusLoadError(errorMessage(loadError, "Maple could not load the ACP service status.")); + return false; + }) + .finally(() => { + if (userIdRef.current === userId) setIsStatusLoading(false); + }); + + const [configLoaded, statusLoaded] = await Promise.all([configRequest, statusRequest]); + if (userIdRef.current !== userId) return; + setOperation(null); + + if (configLoaded && statusLoaded) { + setMessage(shouldRetryConfig ? "Agent policy and status refreshed." : "Status refreshed."); + } else if (configLoaded && shouldRetryConfig) { + setMessage("Agent policy loaded. Service status is still unavailable."); + } + }, [configUserId, savedConfig, userId]); + + useEffect(() => { + statusRequestGenerationRef.current += 1; + if (!userId) { + setConfig(null); + setSavedConfig(null); + setConfigUserId(null); + setStatus(null); + setIsConfigLoading(false); + setIsStatusLoading(false); + setConfigLoadError(null); + setStatusLoadError(null); + setError(null); + setMessage(null); + setOperation(null); + return; + } + + let disposed = false; + let timer: number | undefined; + + setConfig(null); + setSavedConfig(null); + setConfigUserId(null); + setStatus(null); + setIsConfigLoading(true); + setIsStatusLoading(true); + setConfigLoadError(null); + setStatusLoadError(null); + setError(null); + setMessage(null); + setOperation(null); + + const authReady = awaitAgentAuthUser(userId); + const configLoad = authReady + .then(() => mapleAcpService.loadConfig(userId)) + .then((nextConfig) => { + if (disposed) return; + setConfig(nextConfig); + setSavedConfig(nextConfig); + setConfigUserId(userId); + setConfigLoadError(null); + }) + .catch((loadError) => { + if (!disposed) { + setConfigLoadError(errorMessage(loadError, "Maple could not load the agent policy.")); + } + }) + .finally(() => { + if (!disposed) setIsConfigLoading(false); + }); + + const statusLoad = authReady + .then(() => mapleAcpService.getStatus(userId)) + .then((nextStatus) => { + if (disposed) return; + setStatus(nextStatus); + setStatusLoadError(null); + }) + .catch((loadError) => { + if (!disposed) { + setStatusLoadError( + errorMessage(loadError, "Maple could not load the ACP service status.") + ); + } + }) + .finally(() => { + if (!disposed) setIsStatusLoading(false); + }); + + const poll = async () => { + if (disposed) return; + if (operationRef.current === null) { + const requestGeneration = statusRequestGenerationRef.current; + try { + const nextStatus = await mapleAcpService.getStatus(userId); + if ( + !disposed && + requestGeneration === statusRequestGenerationRef.current && + operationRef.current === null + ) { + setStatus(nextStatus); + setStatusLoadError(null); + } + } catch { + // Keep the last known status. Explicit refreshes surface diagnostics. + } + } + if (!disposed) timer = window.setTimeout(poll, STATUS_POLL_INTERVAL_MS); + }; + + void Promise.allSettled([configLoad, statusLoad]).then(() => { + if (!disposed) timer = window.setTimeout(poll, STATUS_POLL_INTERVAL_MS); + }); + return () => { + disposed = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [userId]); + + const savePolicy = async (): Promise => { + if (!userId || !config || !savedConfig || configUserId !== userId) return null; + const operationUserId = userId; + setOperation("save"); + setError(null); + setMessage(null); + try { + const nextConfig = await mapleAcpService.saveConfig(userId, { + ...config, + enabled: status?.enabled ?? config.enabled + }); + if (userIdRef.current !== operationUserId) return null; + setConfig(nextConfig); + setSavedConfig(nextConfig); + setMessage("Agent policy saved."); + return nextConfig; + } catch (saveError) { + if (userIdRef.current === operationUserId) setError(errorMessage(saveError)); + return null; + } finally { + if (userIdRef.current === operationUserId) setOperation(null); + } + }; + + const startService = async () => { + if (!userId || !config || !savedConfig || configUserId !== userId) return; + statusRequestGenerationRef.current += 1; + const operationUserId = userId; + setOperation("start"); + setError(null); + setMessage(null); + try { + const nextConfig = await mapleAcpService.saveConfig(userId, { + ...config, + enabled: status?.enabled ?? config.enabled + }); + if (userIdRef.current !== operationUserId) return; + const nextStatus = await mapleAcpService.start(userId); + if (userIdRef.current !== operationUserId) return; + const startedConfig = { ...nextConfig, enabled: nextStatus.enabled || nextStatus.running }; + setConfig(startedConfig); + setSavedConfig(startedConfig); + setStatus(nextStatus); + setMessage("Maple is ready for local ACP clients."); + } catch (startError) { + if (userIdRef.current === operationUserId) setError(errorMessage(startError)); + } finally { + if (userIdRef.current === operationUserId) setOperation(null); + } + }; + + const stopService = async () => { + if (!userId) return; + statusRequestGenerationRef.current += 1; + const operationUserId = userId; + setOperation("stop"); + setError(null); + setMessage(null); + try { + const nextStatus = await mapleAcpService.stop(userId); + if (userIdRef.current !== operationUserId) return; + setStatus(nextStatus); + setConfig((current) => (current ? { ...current, enabled: false } : current)); + setSavedConfig((current) => (current ? { ...current, enabled: false } : current)); + setMessage("Local ACP connections are disabled."); + } catch (stopError) { + if (userIdRef.current === operationUserId) setError(errorMessage(stopError)); + } finally { + if (userIdRef.current === operationUserId) setOperation(null); + } + }; + + const harnessJson = useMemo( + () => (status?.harness ? serializeBuzzCustomHarness(status.harness) : ""), + [status?.harness] + ); + const displayedConfig = configUserId === userId ? config : null; + const displayedSavedConfig = configUserId === userId ? savedConfig : null; + const running = status?.running === true; + const configReady = isMapleAcpConfigReady(displayedConfig, displayedSavedConfig); + const permissionMode = displayedConfig?.permissionMode ?? "read_only"; + const policyDirty = isMapleAcpPolicyDirty(displayedConfig, displayedSavedConfig); + const mutationsDisabled = isBusy || !userId || !configReady; + const policyMutationsDisabled = mutationsDisabled || running; + + const copyText = async (target: CopyTarget, value: string) => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(target); + window.setTimeout(() => setCopied((current) => (current === target ? null : current)), 2_000); + } catch (copyError) { + setError(errorMessage(copyError, "Maple could not copy to the clipboard.")); + } + }; + + const statusLabel = + status === null + ? "Unavailable" + : running + ? status.connectedClients > 0 + ? "Connected" + : "Ready" + : "Stopped"; + + return ( + Preview} + > + + {copied ? `${copied === "harness" ? "Harness JSON" : copied} copied to clipboard.` : ""} + + +
+ {error && ( + + + {error} + + )} + {configLoadError && ( + + + + {configLoadError} Policy controls remain locked so Maple cannot replace your saved + policy with defaults. Use refresh to try again. + + + )} + {statusLoadError && ( + + + + {statusLoadError} The last known status is preserved. Use refresh to try again. + + + )} + {message && !error && ( + + + {message} + + )} + {status?.error && !error && ( + + + {status.error} + + )} + +
+
+
+ +
+
+
+

+ {isStatusLoading + ? "Checking local ACP service" + : status + ? `ACP service ${statusLabel.toLowerCase()}` + : "ACP service status unavailable"} +

+ + {isStatusLoading ? "Checking" : statusLabel} + +
+

+ {isStatusLoading + ? "Reading the local service status." + : status === null + ? "Refresh to retry. Maple will not assume the service is stopped." + : running + ? status?.connectedClients + ? `${status.connectedClients} local client${status.connectedClients === 1 ? " is" : "s are"} connected.` + : "Waiting for a trusted local ACP client to connect." + : "Disabled by default. Start it when you are ready to connect Buzz."} +

+
+
+
+ + {running ? ( + + ) : ( + + )} +
+
+ +
+ + + +
+ + + + + The bridge does not put your Maple access tokens, API keys, or Buzz private key in its + command or harness configuration. Stopping it disconnects every local client. + + +
+
+ + +
+
+ + +

+ {displayedConfig === null + ? isConfigLoading + ? "Loading your saved agent policy." + : "Your saved policy is unavailable. Refresh before changing or starting ACP." + : permissionMode === "read_only" + ? running + ? "Stop the ACP service before changing its policy. Write-capable tools require approval in Maple Desktop." + : "Write-capable tools require approval in Maple Desktop. This mode is not suitable for unattended Buzz operation." + : "Required for unattended Buzz operation. Connected clients may run commands and modify files without local approval prompts."} +

+
+ + + + + This preview does not yet expose project-root allowlisting. A connected client can + select any absolute working directory Maple can access. Neither policy is an + operating-system sandbox. + + + + {permissionMode === "allow_all" && displayedConfig !== null && ( + + + + Allow all is intended only for clients and projects you trust. Buzz can ask Maple to + run local commands and make external changes without local approval while this + service is active. + + + )} + +
+ +
+
+
+ + +
+
+ + + + +
+

+ Command is the executable path only. Add acp as a + separate argument row in Buzz; do not append it to Command. +

+ + + + + In Buzz managed-agent settings, set parallelism to {BUZZ_MAPLE_AGENT_PARALLELISM}. + Buzz defaults to {BUZZ_DEFAULT_AGENT_PARALLELISM}, while Maple accepts one local ACP + connection by default. + + + +
+
+ + +
+