diff --git a/docs/agent-mode-acp.md b/docs/agent-mode-acp.md new file mode 100644 index 000000000..8f56a1f85 --- /dev/null +++ b/docs/agent-mode-acp.md @@ -0,0 +1,285 @@ +# 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["MapleAgentService\nAgentRuntimeHandle"] + 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, automatic permission classification, runs, and UI events. The surface that starts a run owns any unresolved interactive permission decision: Tauri for a Desktop run and the connected ACP client for an ACP run. 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 surface-scoped approval routing; +- 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 | Yes | Implemented | Maple automatically resolves operations covered by its own policy, then emits a typed, run-scoped request for anything unresolved. ACP offers only `allow_once` and `reject_once`; cancellation, disconnect, transport failure, an unknown option, duplicate response, or reused request ID fails closed. The ACP caller is the sole interactive broker for that run, and Maple Desktop receives no actionable live card. Buzz currently chooses `allow_once` automatically. Maple has no separate non-overridable dangerous-deny policy yet. | +| Basic structured tool calls and results | Partial | Low–Medium | A permission request includes a pending ACP `ToolCallUpdate` with stable ID, title, kind, prompt, and raw input. General tool starts, progress, outputs, and results are not yet projected. Maple's timeline already carries most ordinary card fields, so this is mainly projection work, but lifecycle correlation and bounded rich output still need wire tests. | +| 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. General tool lifecycle 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. + +## Maple-owned runtime service + +The refactor exposes a transport-neutral Maple service around the existing embedded runtime: + +- ensure the account-scoped runtime exists; +- create and delete a Maple task; +- send and cancel a run; +- consume an isolated, bounded event stream for one exact run; +- receive typed permission requests and resolve them through an opaque responder bound to that exact account, task, and run; +- observe a retained terminal result when a run stream closes normally; 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. The service owns account and shutdown admission fences, atomic session creation plus transient tool context, one active run per session, cancellation, cleanup, typed warnings/errors, and an ordered per-run event stream with a retained terminal result. + +```mermaid +flowchart LR + UI["Maple UI"] --> Tauri["Tauri command and event projector"] + ACP["ACP clients"] --> Adapter["ACP adapter and Buzz compatibility"] + Tauri --> Service["MapleAgentService\ntyped operations, leases, and per-run events"] + Adapter --> Service + Service --> Goose["embedded Goose runtime"] +``` + +Tauri remains a thin projection of the Desktop contract: the original command names, arguments, and `agent-event` envelopes are unchanged. Stop and restart now return a small host-lifecycle outcome containing the authoritative runtime status plus any ACP cleanup warning; the frontend unwraps it without changing normal Agent Mode behavior. ACP independently projects the same service operations and run events into ACP requests, notifications, and stop reasons. Socket ownership, protocol negotiation, connection leases, and `BUZZ_*` parsing remain adapter concerns. + +This keeps Maple's domain model a useful superset instead of forcing Maple, Tauri, and ACP into exact wire parity. Neither adapter calls through the other, and the core service imports no Tauri or ACP types. + +## Lifecycle and desktop configuration + +The Agent connections settings surface is fail-closed and hidden by default. To expose it in a macOS or Linux Tauri Desktop development build, set the local Vite override in `frontend/.env.local` (or the build environment) and restart the frontend dev server: + +```dotenv +VITE_FORCE_FEATURE_FLAGS=agent_connections +``` + +This preview gate uses only the local `VITE_FORCE_FEATURE_FLAGS` override; the remote feature-flag service cannot enable it. Web, mobile, and Windows builds keep both the navigation item and direct route unavailable even when the override is present. + +The macOS/Linux desktop settings page is intentionally manual. It can: + +- start and stop the local service; +- show that unresolved approvals belong to the connected ACP client; +- 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's composite host lifecycle attempts ACP shutdown before Agent-runtime stop or restart, and it always attempts the core runtime operation even if ACP cleanup reports an error. Desktop receives both the authoritative runtime result and any ACP cleanup warning, so it can resynchronize successful runtime changes while logout and credential cleanup still fail closed. Local Agent-data/history clearing remains stricter: it proceeds only after ACP has stopped successfully. Application update restart and exit also report failure if either surface cannot shut down. A saved `enabled` value does not auto-start ACP on the next launch; the user must explicitly start it again. + +Allowed-root and maximum-connection changes require Stop, then change and save, then Start. This prevents a new session from racing a policy update and retaining the previous policy. + +## Permissions are policy, not confinement + +`read_only` is the retained serialized name for caller-mediated `smart_approve`; it is not a literal read-only sandbox. Maple automatically approves operations covered by its own classifier and sends every unresolved tool decision to the ACP client. That client may prompt, reject, or approve automatically. Buzz currently selects `allow_once`, so guarded writes can still run unattended. + +The exploratory `allow_all` value previously bypassed the caller through Maple's Auto mode. Current configuration normalization migrates it to `read_only`, and both variants resolve to caller-mediated policy in the native adapter. This preserves old files without retaining a second Maple-owned approval path. + +```mermaid +flowchart LR + Tool["Goose tool request"] --> Policy["Maple automatic policy"] + Policy -->|"covered locally"| Goose["Exact running Goose Agent"] + Policy -->|"unresolved ACP run"| Request["Run-scoped ACP permission request"] + Request --> Caller["Connected ACP client"] + Caller -->|"allow_once or reject_once"| Responder["Opaque exact-run responder"] + Responder --> Goose + Caller -->|"cancel, disconnect, invalid response"| Deny["Fail closed and cancel"] + Deny --> Goose +``` + +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 shells terminate their Unix process group or Windows Job Object immediately after each command to limit descendant retention. Forced async-task shutdown uses the same containment handle. + +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. Because Buzz currently auto-selects `allow_once`, use this integration only with trusted clients, prompts, projects, and toolchains. + +Unix process groups are not a complete sandbox: a command can deliberately call `setsid` or otherwise move a descendant into another process group before cleanup. Windows Job Objects provide stronger descendant containment. A hard Maple process crash or `SIGKILL` also bypasses Maple's in-process cleanup on both platforms, so a surviving descendant may retain an already copied credential. A portable hard credential boundary would require keeping the Buzz signing key out of arbitrary shell environments and exposing only a revocable Maple-owned broker or dedicated tool; that is outside this preview. + +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 then-current unattended `allow_all` policy (historical; current builds migrate this value to caller-mediated approval). + +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. +- ACP runs keep transient events isolated from Maple Desktop. Unresolved permissions are sent only to the connected ACP caller, and Buzz currently auto-selects `allow_once`. +- Each ACP run has an isolated bounded event queue. If any event is dropped, Maple emits an explicit terminal error message and cancels the underlying run. It settles the already-admitted ACP turn without a JSON-RPC error because Buzz treats post-start agent errors as retryable and could otherwise repeat non-idempotent work; missed chunks are not reconstructed. +- Maple applies connection-wide admission to streamed `session/update` notifications and outbound permission requests: at most 256 admitted events and roughly 4 MiB may be in flight. Notification credit returns after the complete line is written to the real local socket; permission-request credit remains held until the caller responds or the connection closes, including after local turn cancellation. A stalled client therefore backpressures the adapter instead of accumulating permission frames or orphaned SDK correlations. A single oversized update or request is rejected and cancels that run. Ordinary JSON-RPC responses still use `agent-client-protocol` 1.0.1's internal outgoing path. +- 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. +- Root and maximum-connection policy changes are rejected while the listener is running. +- The UI polls service status instead of subscribing to lifecycle events. +- Permission mapping and ownership have focused unit coverage, but there is no checked-in full wire-level, socket-lifecycle, reconnect, 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. accept an invocation-scoped permission responder so the host can bind each run to exactly one calling surface; +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/.env.example b/frontend/.env.example index 043d54ce8..78fe4deee 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,5 +2,7 @@ VITE_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 VITE_OPEN_SECRET_API_URL=http://127.0.0.1:3000 #VITE_OS_FLAGS_BASE_URL=https://flags-dev.opensecret.cloud +# Comma-separated local preview flags. Agent connections supports macOS/Linux desktop only. +#VITE_FORCE_FEATURE_FLAGS=agent_connections #VITE_MAPLE_BILLING_API_URL=http://127.0.0.1:3001 #VITE_DEV_MODEL_OVERRIDE=gpt-4o diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 112bef831..073de708d 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -4581,6 +4581,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" name = "maple" version = "3.3.2" dependencies = [ + "agent-client-protocol", "anyhow", "async-trait", "axum", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index a4bf0ab58..728ab64d5 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" @@ -64,7 +64,8 @@ rand = "0.8.6" 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..cfd0ac8f1 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -1,10 +1,11 @@ mod developer_tools; pub(crate) mod provider; mod shell_permission; +mod tool_context; mod web_permission; mod web_tools; -use crate::maple_api::{account_scope, MapleApiAuthState, MapleApiSession}; +use crate::maple_api::{account_scope, MapleApiSession}; use developer_tools::MapleDeveloperClient; use futures_util::StreamExt; use goose::agents::extension::Envs; @@ -38,12 +39,13 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, 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::{mpsc, oneshot, watch, Mutex}; use tokio_util::sync::CancellationToken; +pub(crate) use tool_context::AgentToolContextSpec; +use tool_context::SharedAgentToolContext; use web_permission::{ web_search_request_id, OpenUrlPermissionRequest, WebPermissionClassifier, WebPermissionContext, WebPermissionOutcome, @@ -56,7 +58,6 @@ const DEFAULT_GOOSE_MODE: &str = "smart_approve"; // Keep Goose on its ActionRequired path so Maple can apply the currently selected // policy at every tool boundary, including when the user changes it mid-run. const GOOSE_PERMISSION_ROUTING_MODE: GooseMode = GooseMode::SmartApprove; -const AGENT_EVENT_NAME: &str = "agent-event"; const MAPLE_DEVELOPER_TOOLS: [&str; 7] = [ "read", "shell", @@ -92,7 +93,15 @@ 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_RUN_EVENT_CAPACITY: usize = 256; +const AGENT_SERVICE_OPEN: u8 = 0; +const AGENT_SERVICE_DRAINING: u8 = 1; +const AGENT_SERVICE_DRAINING_ERROR: &str = + "Maple Agent services are draining and cannot accept new work"; +pub(crate) const AGENT_TOOL_CONTEXT_INACTIVE_ERROR: &str = + "Agent tool context access is no longer active"; static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_TOOL_CONTEXT_INSTALLATION_ID: AtomicU64 = AtomicU64::new(1); fn validate_session_model_lock( message_count: usize, @@ -232,7 +241,7 @@ pub struct AgentStartRequest { pub mode: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeStatus { pub running: bool, @@ -291,6 +300,45 @@ pub struct AgentPermissionResponse { pub decision: String, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AgentPermissionRequest { + pub request_id: String, + pub tool_name: String, + pub arguments: serde_json::Map, + pub prompt: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentPermissionDecision { + AllowOnce, + DenyOnce, + Cancel, +} + +impl AgentPermissionDecision { + fn status(self) -> &'static str { + match self { + Self::AllowOnce => "allow_once", + Self::DenyOnce => "deny_once", + Self::Cancel => "cancelled", + } + } + + fn goose_permission(self) -> Permission { + match self { + Self::AllowOnce => Permission::AllowOnce, + Self::DenyOnce => Permission::DenyOnce, + Self::Cancel => Permission::Cancel, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentPermissionRouting { + Desktop, + CallingSurface, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentPermissionModeRequest { @@ -304,6 +352,181 @@ 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 events: mpsc::Receiver, + pub terminal: watch::Receiver>, + pub event_overflowed: Arc, + pub permission_responder: Option, + pub cancellation: Option, +} + +#[derive(Clone)] +pub(crate) struct AgentRunPermissionResponder { + agent: AgentRuntimeHandle, + session_id: Arc, + run_id: Arc, +} + +impl AgentRunPermissionResponder { + pub(crate) async fn respond( + &self, + request_id: String, + decision: AgentPermissionDecision, + ) -> Result<(), String> { + self.agent + .permission_respond_for_run( + self.session_id.as_ref(), + self.run_id.as_ref(), + request_id, + decision, + ) + .await + } +} + +/// Opaque cancellation capability for one run owned by a calling surface. +/// +/// Unlike the Desktop command boundary, an adapter already has the exact run +/// identity. Retaining that identity here prevents it from cancelling another +/// surface's run through a caller-provided run ID. +#[derive(Clone)] +pub(crate) struct AgentRunCancellation { + agent: AgentRuntimeHandle, + session_id: Arc, + run_id: Arc, + routing: AgentPermissionRouting, +} + +impl AgentRunCancellation { + pub(crate) async fn cancel(&self) -> Result<(), String> { + self.agent + .cancel_run_scoped( + self.run_id.as_ref(), + Some(self.session_id.as_ref()), + self.routing, + ) + .await + } +} + +pub(crate) struct CreatedAgentSession { + pub(crate) detail: AgentSessionDetail, + pub(crate) tool_context_lease: Option, +} + +/// Controls whether a surface's events are also projected into Maple Desktop. +/// +/// This is deliberately independent of tool-context ownership. A calling +/// surface can keep its transient run stream isolated while persisted history +/// remains available when Maple Desktop later loads the task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentHostEventPolicy { + Publish, + Suppress, +} + +impl AgentHostEventPolicy { + fn publishes(self) -> bool { + matches!(self, Self::Publish) + } +} + +pub(crate) struct AgentToolContextLease { + service: MapleAgentService, + access: AgentToolContextAccess, +} + +#[derive(Clone)] +pub(crate) struct AgentToolContextAccess { + account_scope: Arc, + session_id: Arc, + installation_id: u64, + context: SharedAgentToolContext, +} + +impl AgentToolContextLease { + pub(crate) fn access(&self) -> AgentToolContextAccess { + self.access.clone() + } + + pub(crate) fn revoke(&self) { + self.access.context.revoke(); + } + + pub(crate) async fn release(self) { + self.revoke(); + let _session_lifecycle = self.service.session_lifecycle.lock().await; + let removed = { + let mut runtime = self.service.inner.lock().await; + let Some(current) = runtime.as_mut() else { + return; + }; + if current.account_scope != self.access.account_scope.as_ref() { + return; + } + take_matching_tool_context( + &mut current.session_tool_contexts, + self.access.session_id.as_ref(), + self.access.installation_id, + &self.access.context, + ) + }; + if let Some(installed) = removed { + installed.context.revoke(); + } + } +} + +impl Drop for AgentToolContextLease { + fn drop(&mut self) { + self.access.context.revoke(); + } +} + +#[derive(Debug, Clone)] +pub(crate) enum AgentRunEvent { + SessionUpdated(AgentSessionSummary), + Started, + TimelineItem(AgentTimelineItem), + PermissionRequested { + request: AgentPermissionRequest, + item: AgentTimelineItem, + }, + SetupWarning(String), + HistoryReplaced, + Error(AgentTimelineItem), + Finished(AgentRunTerminal), +} + +#[derive(Debug, Clone)] +pub(crate) enum AgentServiceEvent { + RuntimeStatus(AgentRuntimeStatus), + SessionCreated(AgentSessionSummary), + SessionUpdated { + session_id: String, + run_id: Option, + session: AgentSessionSummary, + }, + TimelineItem { + session_id: String, + run_id: Option, + item: AgentTimelineItem, + }, + Run { + session_id: String, + run_id: String, + event: AgentRunEvent, + }, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { @@ -346,33 +569,31 @@ pub struct AgentTimelineItem { pub merge: String, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentEventEnvelope { - pub event_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub run_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub item: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub session: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - struct ActiveAgentRun { + agent: Arc, + permission_routing: AgentPermissionRouting, token: CancellationToken, + tool_context: SharedAgentToolContext, session_id: String, + events: AgentRunEventPublisher, cancelled_permission_ids: CancelledPermissionIds, - task_handle: tauri::async_runtime::JoinHandle<()>, + task_handle: tokio::task::JoinHandle<()>, } type PendingPermissionKey = (String, String); -type PendingPermissions = Arc>>; +#[derive(Debug, Clone, PartialEq)] +struct PendingAgentPermission { + run_id: String, + routing: AgentPermissionRouting, + request: AgentPermissionRequest, +} +type PendingPermissions = Arc>>; +type IssuedPermissionIds = Arc>>; + +enum AgentPermissionResponseScope { + Desktop, + CallingSurface { run_id: String }, +} type CancelledPermissionIds = Arc>>; type SessionPermissionModes = Arc>>; @@ -381,6 +602,7 @@ struct AgentRuntime { session_manager: Arc, maple_api_session: Arc, active_runs: HashMap, + session_tool_contexts: HashMap, permission_modes: SessionPermissionModes, web_tool_state: Arc, project_root: PathBuf, @@ -389,32 +611,281 @@ struct AgentRuntime { account_scope: String, } +struct InstalledAgentToolContext { + installation_id: u64, + context: SharedAgentToolContext, + owner: AgentToolContextOwner, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AgentToolContextOwner { + Maple, + Leased, +} + +struct PendingAgentToolContextInstallation { + context: SharedAgentToolContext, + committed: bool, +} + +impl PendingAgentToolContextInstallation { + fn new(context: SharedAgentToolContext) -> Self { + Self { + context, + committed: false, + } + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for PendingAgentToolContextInstallation { + fn drop(&mut self) { + if !self.committed { + self.context.revoke(); + } + } +} + +fn take_matching_tool_context( + contexts: &mut HashMap, + session_id: &str, + installation_id: u64, + context: &SharedAgentToolContext, +) -> Option { + let matches = contexts.get(session_id).is_some_and(|installed| { + installed.installation_id == installation_id && installed.context.ptr_eq(context) + }); + matches.then(|| { + contexts + .remove(session_id) + .expect("matching Agent tool context must still exist") + }) +} + +fn resolve_session_tool_context( + contexts: &mut HashMap, + account_scope: &str, + session_id: &str, + access: Option<&AgentToolContextAccess>, + default_spec: &AgentToolContextSpec, +) -> Result { + if let Some(access) = access { + if access.account_scope.as_ref() != account_scope + || access.session_id.as_ref() != session_id + { + return Err("Agent tool context access does not match this task".to_string()); + } + let installed = contexts + .get(session_id) + .filter(|installed| { + installed.owner == AgentToolContextOwner::Leased + && installed.installation_id == access.installation_id + && installed.context.ptr_eq(&access.context) + && !installed.context.is_revoked() + }) + .ok_or_else(|| AGENT_TOOL_CONTEXT_INACTIVE_ERROR.to_string())?; + return Ok(installed.context.clone()); + } + + if contexts + .get(session_id) + .is_some_and(|installed| installed.context.is_revoked()) + { + contexts.remove(session_id); + } + if let Some(installed) = contexts.get(session_id) { + if installed.owner == AgentToolContextOwner::Leased { + return Err("Agent task is controlled by another Agent surface".to_string()); + } + return Ok(installed.context.clone()); + } + + let context = SharedAgentToolContext::new(default_spec.clone()); + contexts.insert( + session_id.to_string(), + InstalledAgentToolContext { + installation_id: next_tool_context_installation_id(), + context: context.clone(), + owner: AgentToolContextOwner::Maple, + }, + ); + Ok(context) +} + impl AgentRuntime { - fn status(&self) -> AgentRuntimeStatus { + fn desktop_status(&self) -> AgentRuntimeStatus { AgentRuntimeStatus { running: true, project_root: Some(path_string(&self.project_root)), model: Some(self.model.clone()), mode: Some(self.mode.clone()), - active_runs: self - .active_runs - .iter() - .map(|(run_id, run)| (run.session_id.clone(), run_id.clone())) - .collect(), + // AgentRuntimeStatus is Maple Desktop's projection. Calling surfaces + // retain their own run handles and lifecycle signals instead of + // becoming actionable through the Tauri command boundary. + active_runs: active_run_status(self.active_runs.iter().map(|(run_id, run)| { + ( + run_id.as_str(), + run.session_id.as_str(), + run.permission_routing, + ) + })), + } + } +} + +fn active_run_status<'a>( + runs: impl IntoIterator, +) -> HashMap { + runs.into_iter() + .filter(|(_, _, routing)| *routing == AgentPermissionRouting::Desktop) + .map(|(run_id, session_id, _)| (session_id.to_string(), run_id.to_string())) + .collect() +} + +#[derive(Clone)] +pub(crate) struct AgentPathLayout { + config_root: PathBuf, + local_data_root: PathBuf, +} + +impl AgentPathLayout { + pub(crate) fn from_app_roots(app_config_root: PathBuf, app_local_data_root: PathBuf) -> Self { + Self { + config_root: app_config_root.join("agent"), + local_data_root: app_local_data_root.join("agent"), + } + } +} + +pub(crate) trait AgentEventSink: Send + Sync + 'static { + fn emit(&self, event: &AgentServiceEvent); +} + +#[derive(Clone)] +struct AgentEventDispatcher { + sink: Arc, +} + +impl AgentEventDispatcher { + fn new(sink: Arc) -> Self { + Self { sink } + } +} + +#[derive(Clone)] +struct AgentRunEventPublisher { + dispatcher: AgentEventDispatcher, + session_id: Arc, + run_id: Arc, + sender: mpsc::Sender, + order: Arc>, + host_events: AgentHostEventPolicy, + overflowed: Arc, +} + +impl AgentRunEventPublisher { + fn new( + dispatcher: AgentEventDispatcher, + session_id: String, + run_id: String, + host_events: AgentHostEventPolicy, + ) -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(AGENT_RUN_EVENT_CAPACITY); + ( + Self { + dispatcher, + session_id: Arc::from(session_id), + run_id: Arc::from(run_id), + sender, + order: Arc::new(Mutex::new(())), + host_events, + overflowed: Arc::new(AtomicBool::new(false)), + }, + receiver, + ) + } + + fn overflow_flag(&self) -> Arc { + Arc::clone(&self.overflowed) + } + + async fn publish(&self, event: AgentRunEvent) { + let _order = self.order.lock().await; + if self.host_events.publishes() { + emit_agent_event( + &self.dispatcher, + AgentServiceEvent::Run { + session_id: self.session_id.to_string(), + run_id: self.run_id.to_string(), + event: event.clone(), + }, + ); + } + // Desktop deliberately drops this receiver after obtaining the run ID. + // ACP retains it as an isolated, bounded stream for the run. A slow + // protocol consumer must never backpressure Goose or lifecycle cleanup. + // Queue saturation is retained as an explicit error signal so no ACP + // caller can mistake a truncated stream for a complete response. + match self.sender.try_send(event) { + Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + self.overflowed.store(true, Ordering::Release); + } } } } -pub struct AgentRuntimeState { +#[derive(Clone)] +pub(crate) struct MapleAgentHostResources { + paths: AgentPathLayout, + events: AgentEventDispatcher, + default_tool_context: AgentToolContextSpec, +} + +impl MapleAgentHostResources { + pub(crate) fn new( + paths: AgentPathLayout, + event_sink: Arc, + default_tool_context: AgentToolContextSpec, + ) -> Self { + Self { + paths, + events: AgentEventDispatcher::new(event_sink), + default_tool_context, + } + } +} + +#[derive(Clone)] +pub struct MapleAgentService { + host: MapleAgentHostResources, inner: Arc>>, runtime_lifecycle: Arc>, account_generations: Arc>>, session_lifecycle: Arc>, pending_permissions: PendingPermissions, live_timelines: LiveTimelines, + admission: Arc, +} + +#[derive(Clone)] +pub(crate) struct AgentRuntimeHandle { + service: MapleAgentService, + user_id: Arc, + account_scope: Arc, + generation: u64, } -type LiveTimelines = Arc>>; +type LiveTimelines = Arc>>; + +#[derive(Clone, Debug, PartialEq)] +struct LiveTimelineEntry { + routing: AgentPermissionRouting, + timeline: LiveTimeline, +} #[derive(Clone, Debug, PartialEq)] enum LiveTimeline { @@ -448,19 +919,71 @@ impl LiveTimeline { } } -impl AgentRuntimeState { - pub fn new() -> Self { +impl MapleAgentService { + pub(crate) fn new(host: MapleAgentHostResources) -> Self { Self { + host, inner: Arc::new(Mutex::new(None)), runtime_lifecycle: Arc::new(Mutex::new(())), account_generations: Arc::new(Mutex::new(HashMap::new())), session_lifecycle: Arc::new(Mutex::new(())), pending_permissions: Arc::new(Mutex::new(HashMap::new())), live_timelines: Arc::new(Mutex::new(HashMap::new())), + admission: Arc::new(AtomicU8::new(AGENT_SERVICE_OPEN)), + } + } + + /// Bind subsequent operations to one Maple account and one data generation. + /// + /// Desktop commands create a fresh handle at their boundary. Long-lived + /// adapters such as ACP retain a handle, which makes account clearing an + /// explicit revocation point instead of silently rebinding the adapter. + pub(crate) async fn handle_for_user( + &self, + user_id: &str, + ) -> Result { + let account_scope = account_scope(user_id)?; + let generation = account_generation(self, &account_scope).await; + Ok(AgentRuntimeHandle { + service: self.clone(), + user_id: Arc::from(user_id), + account_scope: Arc::from(account_scope), + generation, + }) + } + + /// Stop admitting mutations before host teardown begins. Existing work and + /// cleanup operations remain able to drain through their dedicated paths. + pub(crate) fn begin_draining(&self) { + self.admission + .store(AGENT_SERVICE_DRAINING, Ordering::Release); + } + + /// Reopen admission only when a requested update restart was abandoned and + /// the current Maple process will continue serving the user. + pub(crate) fn reopen_after_failed_shutdown(&self) { + self.admission.store(AGENT_SERVICE_OPEN, Ordering::Release); + } + + pub(crate) fn ensure_accepting_new_work(&self) -> Result<(), String> { + if self.admission.load(Ordering::Acquire) == AGENT_SERVICE_OPEN { + Ok(()) + } else { + Err(AGENT_SERVICE_DRAINING_ERROR.to_string()) } } } +impl AgentRuntimeHandle { + pub(crate) async fn verify_generation(&self) -> Result<(), String> { + ensure_account_generation(&self.service, &self.account_scope, self.generation).await + } + + pub(crate) fn ensure_accepting_new_work(&self) -> Result<(), String> { + self.service.ensure_accepting_new_work() + } +} + fn ensure_runtime_account(runtime: &AgentRuntime, account_scope: &str) -> Result<(), String> { ensure_account_scope(&runtime.account_scope, account_scope) } @@ -473,7 +996,7 @@ fn ensure_account_scope(current_scope: &str, requested_scope: &str) -> Result<() } } -async fn account_generation(state: &AgentRuntimeState, account_scope: &str) -> u64 { +async fn account_generation(state: &MapleAgentService, account_scope: &str) -> u64 { *state .account_generations .lock() @@ -483,7 +1006,7 @@ async fn account_generation(state: &AgentRuntimeState, account_scope: &str) -> u } async fn ensure_account_generation( - state: &AgentRuntimeState, + state: &MapleAgentService, account_scope: &str, expected: u64, ) -> Result<(), String> { @@ -494,7 +1017,7 @@ async fn ensure_account_generation( } } -async fn advance_account_generation(state: &AgentRuntimeState, account_scope: &str) -> u64 { +async fn advance_account_generation(state: &MapleAgentService, account_scope: &str) -> u64 { let mut generations = state.account_generations.lock().await; let generation = generations.entry(account_scope.to_string()).or_default(); *generation = generation @@ -512,6 +1035,14 @@ fn next_run_id() -> String { format!("run_{}_{sequence}", unix_ms()) } +fn next_tool_context_installation_id() -> u64 { + NEXT_TOOL_CONTEXT_INSTALLATION_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .expect("Agent Mode exhausted its tool context installation IDs") +} + fn session_title_from_prompt(prompt: &str) -> String { let collapsed = prompt.split_whitespace().collect::>().join(" "); if collapsed.chars().count() <= MAX_AGENT_SESSION_TITLE_CHARS { @@ -533,83 +1064,131 @@ fn should_name_session_from_prompt(session: &Session) -> bool { && session.name == DEFAULT_AGENT_SESSION_TITLE } -async fn pending_permissions_for_sessions( +async fn take_pending_permissions_for_runs( pending_permissions: &PendingPermissions, - session_ids: &[String], -) -> Vec<(String, String)> { - let pending = pending_permissions.lock().await; - pending + run_ids: &[String], +) -> Vec<(PendingPermissionKey, PendingAgentPermission)> { + let mut pending = pending_permissions.lock().await; + let keys = pending .keys() - .filter(|(session_id, _)| session_ids.contains(session_id)) - .map(|(session_id, request_id)| (request_id.clone(), session_id.clone())) + .filter(|key| { + pending + .get(*key) + .is_some_and(|request| run_ids.contains(&request.run_id)) + }) + .cloned() + .collect::>(); + keys.into_iter() + .filter_map(|key| pending.remove(&key).map(|request| (key, request))) .collect() } -async fn cancel_pending_permissions_for_sessions( - agent_manager: &Arc, +async fn cancel_pending_permissions_for_runs( pending_permissions: &PendingPermissions, - session_ids: &[String], -) -> Vec<(String, String)> { + run_ids: &[String], + agents_by_run: &HashMap>, +) -> Vec<(PendingPermissionKey, PendingAgentPermission)> { let mut cancelled = Vec::new(); - for (request_id, session_id) in - pending_permissions_for_sessions(pending_permissions, session_ids).await + for ((session_id, request_id), request) in + take_pending_permissions_for_runs(pending_permissions, run_ids).await { - match agent_manager.get_or_create_agent(session_id.clone()).await { - Ok(agent) => { - agent - .handle_confirmation( - request_id.clone(), - PermissionConfirmation { - principal_type: PrincipalType::Tool, - permission: Permission::Cancel, - }, - ) - .await; - let mut pending = pending_permissions.lock().await; - pending.remove(&(session_id.clone(), request_id.clone())); - cancelled.push((request_id, session_id)); - } - Err(error) => { - log::warn!( - "Failed to cancel pending Agent Mode permission for session {session_id}: {error}" - ); - } + if let Some(agent) = agents_by_run.get(&request.run_id) { + agent + .handle_confirmation( + request_id.clone(), + PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: Permission::Cancel, + }, + ) + .await; + } else { + log::warn!( + "Failed to resolve the running Agent for pending permission {request_id} in {session_id}" + ); } + cancelled.push(((session_id, request_id), request)); } cancelled } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingPermissionRegistration { + Registered, + Existing, + Rejected, +} + async fn register_pending_permission( pending_permissions: &PendingPermissions, - request_id: &str, + issued_permission_ids: &IssuedPermissionIds, session_id: &str, + run_id: &str, + routing: AgentPermissionRouting, + request: AgentPermissionRequest, cancel_token: &CancellationToken, -) -> bool { +) -> PendingPermissionRegistration { if cancel_token.is_cancelled() { - return false; + return PendingPermissionRegistration::Rejected; + } + let key = (session_id.to_string(), request.request_id.clone()); + let pending_request = PendingAgentPermission { + run_id: run_id.to_string(), + routing, + request, + }; + { + let mut pending = pending_permissions.lock().await; + match pending.get(&key) { + Some(existing) if existing == &pending_request => { + return PendingPermissionRegistration::Existing; + } + Some(_) => { + // Reusing a Goose request ID with different ownership or payload + // invalidates the old capability. Leaving it resolvable would let + // a stale caller approve a different operation under the reused ID. + pending.remove(&key); + return PendingPermissionRegistration::Rejected; + } + None => {} + } + } + { + let mut issued = issued_permission_ids.lock().await; + if !issued.insert(key.1.clone()) { + pending_permissions.lock().await.remove(&key); + return PendingPermissionRegistration::Rejected; + } } let mut pending = pending_permissions.lock().await; - let key = (session_id.to_string(), request_id.to_string()); - pending.insert(key.clone(), ()); + match pending.entry(key.clone()) { + std::collections::hash_map::Entry::Occupied(existing) => { + existing.remove(); + return PendingPermissionRegistration::Rejected; + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(pending_request); + } + } if cancel_token.is_cancelled() { pending.remove(&key); - false + PendingPermissionRegistration::Rejected } else { - true + PendingPermissionRegistration::Registered } } -async fn stop_runtime_for_user(state: &AgentRuntimeState, user_id: &str) -> Result<(), String> { +async fn stop_runtime_for_user(state: &MapleAgentService, user_id: &str) -> Result<(), String> { let account_scope = account_scope(user_id)?; stop_runtime_inner(state, Some(&account_scope)).await } async fn stop_runtime_inner( - state: &AgentRuntimeState, + state: &MapleAgentService, requested_scope: Option<&str>, ) -> Result<(), String> { let session_lifecycle_guard = state.session_lifecycle.lock().await; - let (agent_manager, active_runs, web_tool_state) = { + let (active_runs, web_tool_state, tool_contexts) = { let mut runtime = state.inner.lock().await; let Some(current) = runtime.as_mut() else { return Ok(()); @@ -618,40 +1197,37 @@ async fn stop_runtime_inner( ensure_runtime_account(current, account_scope)?; } ( - Arc::clone(¤t.agent_manager), std::mem::take(&mut current.active_runs), Arc::clone(¤t.web_tool_state), + std::mem::take(&mut current.session_tool_contexts), ) }; - let session_ids = active_runs - .values() - .map(|run| run.session_id.clone()) - .collect::>(); - let cancelled_permission_ids_by_session = active_runs - .values() - .map(|run| { - ( - run.session_id.clone(), - Arc::clone(&run.cancelled_permission_ids), - ) - }) + for installed in tool_contexts.into_values() { + installed.context.revoke(); + } + + let run_ids = active_runs.keys().cloned().collect::>(); + let agents_by_run = active_runs + .iter() + .map(|(run_id, run)| (run_id.clone(), Arc::clone(&run.agent))) + .collect::>(); + let cancelled_permission_ids_by_run = active_runs + .iter() + .map(|(run_id, run)| (run_id.clone(), Arc::clone(&run.cancelled_permission_ids))) .collect::>(); let mut task_handles = Vec::with_capacity(active_runs.len()); for (_, active_run) in active_runs { // Cancel first so an ActionRequired event racing this snapshot will // take the immediate-cancel path in register_pending_permission. - active_run.token.cancel(); + active_run.tool_context.cancel_run(&active_run.token); task_handles.push(active_run.task_handle); } - let cancelled_permissions = cancel_pending_permissions_for_sessions( - &agent_manager, - &state.pending_permissions, - &session_ids, - ) - .await; - for (request_id, session_id) in cancelled_permissions { - if let Some(cancelled_permission_ids) = cancelled_permission_ids_by_session.get(&session_id) + let cancelled_permissions = + cancel_pending_permissions_for_runs(&state.pending_permissions, &run_ids, &agents_by_run) + .await; + for ((_, request_id), pending) in cancelled_permissions { + if let Some(cancelled_permission_ids) = cancelled_permission_ids_by_run.get(&pending.run_id) { cancelled_permission_ids.lock().await.insert(request_id); } @@ -668,7 +1244,7 @@ async fn stop_runtime_inner( } async fn join_agent_tasks( - mut task_handles: Vec>, + mut task_handles: Vec>, graceful_timeout: std::time::Duration, ) { let graceful = futures_util::future::join_all(task_handles.iter_mut()); @@ -686,65 +1262,61 @@ async fn join_agent_tasks( } } -pub async fn shutdown_agent_runtime(app_handle: &AppHandle) -> Result<(), String> { - let state = app_handle.state::(); - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - stop_runtime_inner(&state, None).await +impl MapleAgentService { + pub(crate) async fn shutdown_all(&self) -> Result<(), String> { + let _runtime_lifecycle_guard = self.runtime_lifecycle.lock().await; + stop_runtime_inner(self, None).await + } } -#[tauri::command] -pub async fn agent_get_runtime_status( - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> Result { - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - let account_scope = account_scope(&user_id)?; - let runtime = state.inner.lock().await; - if let Some(current) = runtime.as_ref() { - ensure_runtime_account(current, &account_scope)?; - return Ok(current.status()); - } - Ok(stopped_status()) -} - -#[tauri::command] -pub async fn agent_start_runtime( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - api_auth_state: State<'_, MapleApiAuthState>, - user_id: String, - request: Option, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - start_runtime_for_user(app_handle, &state, &api_auth_state, user_id, request).await +impl AgentRuntimeHandle { + pub(crate) async fn status(&self) -> Result { + let _runtime_lifecycle_guard = self.service.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let runtime = self.service.inner.lock().await; + if let Some(current) = runtime.as_ref() { + ensure_runtime_account(current, &self.account_scope)?; + return Ok(current.desktop_status()); + } + Ok(stopped_status()) + } + + pub(crate) async fn start( + &self, + maple_api_session: Arc, + request: Option, + ) -> Result { + let _runtime_lifecycle_guard = self.service.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + start_runtime_for_user(&self.service, maple_api_session, &self.user_id, request).await + } } async fn start_runtime_for_user( - app_handle: AppHandle, - state: &AgentRuntimeState, - api_auth_state: &MapleApiAuthState, - user_id: String, + state: &MapleAgentService, + maple_api_session: Arc, + user_id: &str, request: Option, ) -> Result { - let account_scope = account_scope(&user_id)?; + let account_scope = account_scope(user_id)?; { let runtime = state.inner.lock().await; if let Some(current) = runtime.as_ref() { ensure_runtime_account(current, &account_scope)?; - return Ok(current.status()); + return Ok(current.desktop_status()); } } - let maple_api_session = api_auth_state.session_for(&user_id).await?; + ensure_account_scope(maple_api_session.account_scope(), &account_scope).map_err(|_| { + "Maple API authentication belongs to a different signed-in account".to_string() + })?; maple_api_session .validate_user() .await .map_err(|error| format!("Failed to validate Maple API authentication: {error}"))?; - let mut agent_config = load_agent_config_inner(&app_handle, &user_id) + let mut agent_config = load_agent_config_inner(&state.host.paths, user_id) .map_err(|error| format!("Failed to load Agent config: {error}"))?; let request = request.unwrap_or(AgentStartRequest { project_root: None, @@ -762,7 +1334,7 @@ async fn start_runtime_for_user( .unwrap_or_else(|| DEFAULT_GOOSE_MODE.to_string()); parse_user_permission_mode(&mode)?; - let config_dir = agent_config_dir(&app_handle, &user_id).map_err(|e| e.to_string())?; + let config_dir = agent_config_dir(&state.host.paths, user_id).map_err(|e| e.to_string())?; let goose_path_root = config_dir.join("goose"); fs::create_dir_all(goose_path_root.join("data")) .map_err(|e| format!("Failed to create Goose data dir: {e}"))?; @@ -774,7 +1346,7 @@ async fn start_runtime_for_user( reset_maple_owned_permission_file(&goose_path_root.join("config").join("permission.yaml"))?; configure_embedded_goose( - &agent_root_dir(&app_handle) + &agent_root_dir(&state.host.paths) .map_err(|e| e.to_string())? .join("goose-runtime"), &model, @@ -808,6 +1380,7 @@ async fn start_runtime_for_user( session_manager, maple_api_session, active_runs: HashMap::new(), + session_tool_contexts: HashMap::new(), permission_modes: Arc::new(Mutex::new(HashMap::new())), web_tool_state: Arc::new(WebToolState::default()), project_root: project_root.clone(), @@ -815,7 +1388,7 @@ async fn start_runtime_for_user( mode: mode.clone(), account_scope, }; - let status = runtime.status(); + let status = runtime.desktop_status(); { let mut guard = state.inner.lock().await; @@ -827,844 +1400,905 @@ async fn start_runtime_for_user( // here would incorrectly move that visible project to the top of the manual order. agent_config.default_project_root = Some(path_string(&project_root)); agent_config.default_model = model; - let _ = save_agent_config_inner(&app_handle, &user_id, &agent_config); + let _ = save_agent_config_inner(&state.host.paths, user_id, &agent_config); emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "runtimeStatus".to_string(), - session_id: None, - run_id: None, - item: None, - status: Some(status.clone()), - session: None, - message: None, - }, + &state.host.events, + AgentServiceEvent::RuntimeStatus(status.clone()), ); Ok(status) } -#[tauri::command] -pub async fn agent_stop_runtime( - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - stop_runtime_for_user(&state, &user_id).await?; - Ok(stopped_status()) -} - -#[tauri::command] -pub async fn agent_restart_runtime( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - api_auth_state: State<'_, MapleApiAuthState>, - user_id: String, - request: Option, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - stop_runtime_for_user(&state, &user_id).await?; - start_runtime_for_user(app_handle, &state, &api_auth_state, user_id, request).await -} - -#[tauri::command] -pub async fn agent_clear_user_data( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> Result<(), String> { - let requested_scope = account_scope(&user_id)?; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - advance_account_generation(&state, &requested_scope).await; - let is_running_account = { - let runtime = state.inner.lock().await; - runtime - .as_ref() - .is_some_and(|current| current.account_scope == requested_scope) - }; - if is_running_account { - stop_runtime_for_user(&state, &user_id).await?; - } - - let account_dir = - account_config_dir_path(&app_handle, &user_id).map_err(|error| error.to_string())?; - match fs::remove_dir_all(account_dir) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(format!("Failed to clear Agent Mode data: {error}")), - } - let local_account_dir = - account_local_data_dir_path(&app_handle, &user_id).map_err(|error| error.to_string())?; - match fs::remove_dir_all(local_account_dir) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "Failed to clear device-local Agent Mode data: {error}" - )) +impl AgentRuntimeHandle { + pub(crate) async fn stop(&self) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + stop_runtime_for_user(state, &self.user_id).await?; + Ok(stopped_status()) + } + + pub(crate) async fn restart( + &self, + maple_api_session: Arc, + request: Option, + ) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + stop_runtime_for_user(state, &self.user_id).await?; + start_runtime_for_user(state, maple_api_session, &self.user_id, request).await + } + + pub(crate) async fn clear_data(&self) -> Result<(), String> { + let state = &self.service; + let requested_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + advance_account_generation(state, requested_scope).await; + let is_running_account = { + let runtime = state.inner.lock().await; + runtime + .as_ref() + .is_some_and(|current| current.account_scope == requested_scope) + }; + if is_running_account { + stop_runtime_for_user(state, &self.user_id).await?; } - } - Ok(()) -} -#[tauri::command] -pub async fn agent_clear_user_history( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> Result<(), String> { - let requested_scope = account_scope(&user_id)?; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - advance_account_generation(&state, &requested_scope).await; - let is_running_account = { - let runtime = state.inner.lock().await; - runtime - .as_ref() - .is_some_and(|current| current.account_scope == requested_scope) - }; - if is_running_account { - stop_runtime_for_user(&state, &user_id).await?; - } - - let account_dir = - account_config_dir_path(&app_handle, &user_id).map_err(|error| error.to_string())?; - clear_agent_history(&account_dir) - .map_err(|error| format!("Failed to clear Agent Mode history: {error}")) -} - -#[tauri::command] -pub async fn agent_load_config( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn agent_save_config( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - config: AgentConfig, -) -> Result<(), String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - // MCP definitions have a dedicated mutation command. Preserve them here so - // a delayed project/model preference save cannot overwrite newer servers. - let mut next = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; - next.default_project_root = config.default_project_root; - next.default_model = config.default_model; - save_agent_config_inner(&app_handle, &user_id, &next).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn agent_list_mcp_servers( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let config = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; - normalize_mcp_servers(config.mcp_servers) -} - -#[tauri::command] -pub async fn agent_save_mcp_servers( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - servers: Vec, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let servers = normalize_mcp_servers(servers)?; - let mut config = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; - config.mcp_servers = servers.clone(); - save_agent_config_inner(&app_handle, &user_id, &config).map_err(|e| e.to_string())?; - - Ok(servers) -} - -#[tauri::command] -pub async fn agent_list_recent_project_roots( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - load_recent_project_roots_inner(&app_handle, &user_id).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn agent_save_recent_project_root( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - path: String, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let project_root = normalize_project_root(Path::new(&path))?; - let mut config = - load_agent_config_inner(&app_handle, &user_id).map_err(|error| error.to_string())?; - let canonical_path = path_string(&project_root); - let restoring = config - .removed_project_roots - .iter() - .any(|removed| removed == &canonical_path); - let roots = if restoring { - restore_explicit_project_root_inner(&app_handle, &user_id, &project_root) - } else { - register_explicit_project_root_inner(&app_handle, &user_id, &project_root) - } - .map_err(|error| error.to_string())?; - - config - .removed_project_roots - .retain(|removed| removed != &canonical_path); - config.default_project_root = Some(canonical_path.clone()); - save_agent_config_inner(&app_handle, &user_id, &config).map_err(|error| error.to_string())?; - // Clear the device-local tombstone last. If registration or ordinary - // config persistence fails, the project remains hidden. - if restoring { - save_removed_project_roots_inner(&app_handle, &user_id, &config.removed_project_roots) + let account_dir = account_config_dir_path(&state.host.paths, &self.user_id) .map_err(|error| error.to_string())?; + match fs::remove_dir_all(account_dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("Failed to clear Agent Mode data: {error}")), + } + let local_account_dir = account_local_data_dir_path(&state.host.paths, &self.user_id) + .map_err(|error| error.to_string())?; + match fs::remove_dir_all(local_account_dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Failed to clear device-local Agent Mode data: {error}" + )) + } + } + Ok(()) } - Ok(AgentProjectRootRegistration { - project_root: canonical_path, - roots, - config, - }) -} - -#[tauri::command] -pub async fn agent_remove_project_root( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - path: String, - fallback_path: Option, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let _session_lifecycle_guard = state.session_lifecycle.lock().await; - - let path = path.trim().to_string(); - if !structurally_valid_project_root(&path) { - return Err("Project path must be an absolute folder path".to_string()); - } - let fallback_path = fallback_path - .map(|fallback| fallback.trim().to_string()) - .filter(|fallback| !fallback.is_empty()); - if let Some(fallback) = fallback_path.as_deref() { - if fallback == path || !structurally_valid_project_root(fallback) { - return Err("Project fallback must be a different absolute folder path".to_string()); + pub(crate) async fn clear_history(&self) -> Result<(), String> { + let state = &self.service; + let requested_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + advance_account_generation(state, requested_scope).await; + let is_running_account = { + let runtime = state.inner.lock().await; + runtime + .as_ref() + .is_some_and(|current| current.account_scope == requested_scope) + }; + if is_running_account { + stop_runtime_for_user(state, &self.user_id).await?; } - } - let (session_manager, active_session_ids) = { - let runtime = state.inner.lock().await; - match runtime.as_ref() { - Some(current) => { - ensure_runtime_account(current, &account_scope)?; - ( - Arc::clone(¤t.session_manager), - current - .active_runs - .values() - .map(|run| run.session_id.clone()) - .collect::>(), - ) - } - None => ( - account_session_manager(&app_handle, &user_id)?, - HashSet::new(), - ), + let account_dir = account_config_dir_path(&state.host.paths, &self.user_id) + .map_err(|error| error.to_string())?; + clear_agent_history(&account_dir) + .map_err(|error| format!("Failed to clear Agent Mode history: {error}")) + } +} + +impl AgentRuntimeHandle { + pub(crate) async fn load_config(&self) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + load_agent_config_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string()) + } + + pub(crate) async fn save_config(&self, config: AgentConfig) -> Result<(), String> { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + // MCP definitions have a dedicated mutation command. Preserve them here so + // a delayed project/model preference save cannot overwrite newer servers. + let mut next = + load_agent_config_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string())?; + next.default_project_root = config.default_project_root; + next.default_model = config.default_model; + save_agent_config_inner(&state.host.paths, &self.user_id, &next).map_err(|e| e.to_string()) + } + + pub(crate) async fn list_mcp_servers(&self) -> Result, String> { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let config = + load_agent_config_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string())?; + normalize_mcp_servers(config.mcp_servers) + } + + pub(crate) async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String> { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let servers = normalize_mcp_servers(servers)?; + let mut config = + load_agent_config_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string())?; + config.mcp_servers = servers.clone(); + save_agent_config_inner(&state.host.paths, &self.user_id, &config) + .map_err(|e| e.to_string())?; + + Ok(servers) + } + + pub(crate) async fn list_recent_project_roots(&self) -> Result, String> { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + load_recent_project_roots_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string()) + } + + pub(crate) async fn save_recent_project_root( + &self, + path: String, + ) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let project_root = normalize_project_root(Path::new(&path))?; + let mut config = load_agent_config_inner(&state.host.paths, &self.user_id) + .map_err(|error| error.to_string())?; + let canonical_path = path_string(&project_root); + let restoring = config + .removed_project_roots + .iter() + .any(|removed| removed == &canonical_path); + let roots = if restoring { + restore_explicit_project_root_inner(&state.host.paths, &self.user_id, &project_root) + } else { + register_explicit_project_root_inner(&state.host.paths, &self.user_id, &project_root) } - }; - let sessions = session_manager - .list_all_sessions() - .await - .map_err(|error| format!("Failed to inspect Agent tasks: {error}"))?; - let session_roots = sessions - .iter() - .map(|session| (session.id.clone(), path_string(&session.working_dir))) - .collect::>(); - if project_has_active_session_run(&session_roots, &active_session_ids, &path) { - return Err("Stop the running agent before removing this project".to_string()); - } - - let mut config = - load_agent_config_inner(&app_handle, &user_id).map_err(|error| error.to_string())?; - apply_project_root_removal(&mut config, &path, fallback_path.as_deref())?; - // The tombstone is the only persistent removal state. Saving the fallback - // into roaming config would let this device's removal alter another - // device. Runtime/UI use the fallback immediately; startup filters the - // stale hidden default before selecting any project. - save_removed_project_roots_inner(&app_handle, &user_id, &config.removed_project_roots) .map_err(|error| error.to_string())?; - let mut runtime = state.inner.lock().await; - if let Some(current) = runtime.as_mut() { - update_runtime_project_root_after_removal( - &mut current.project_root, - &path, - fallback_path.as_deref(), - ); + config + .removed_project_roots + .retain(|removed| removed != &canonical_path); + config.default_project_root = Some(canonical_path.clone()); + save_agent_config_inner(&state.host.paths, &self.user_id, &config) + .map_err(|error| error.to_string())?; + // Clear the device-local tombstone last. If registration or ordinary + // config persistence fails, the project remains hidden. + if restoring { + save_removed_project_roots_inner( + &state.host.paths, + &self.user_id, + &config.removed_project_roots, + ) + .map_err(|error| error.to_string())?; + } + + Ok(AgentProjectRootRegistration { + project_root: canonical_path, + roots, + config, + }) } - Ok(config) -} + pub(crate) async fn remove_project_root( + &self, + path: String, + fallback_path: Option, + ) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + + let path = path.trim().to_string(); + if !structurally_valid_project_root(&path) { + return Err("Project path must be an absolute folder path".to_string()); + } + let fallback_path = fallback_path + .map(|fallback| fallback.trim().to_string()) + .filter(|fallback| !fallback.is_empty()); + if let Some(fallback) = fallback_path.as_deref() { + if fallback == path || !structurally_valid_project_root(fallback) { + return Err("Project fallback must be a different absolute folder path".to_string()); + } + } -#[tauri::command] -pub async fn agent_get_project_skills_trust( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - path: String, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let requested = Path::new(path.trim()); - if !requested.is_dir() { - return Ok(AgentProjectSkillsTrustStatus { - path: path_string(requested), - decision: None, - available: false, - }); - } - let project_root = normalize_project_root(requested)?; - let config = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; - Ok(project_skills_trust_status(&config, &project_root, true)) -} + let (session_manager, active_session_ids) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &self.account_scope)?; + ( + Arc::clone(¤t.session_manager), + current + .active_runs + .values() + .map(|run| run.session_id.clone()) + .collect::>(), + ) + } + None => ( + account_session_manager(&state.host.paths, &self.user_id)?, + HashSet::new(), + ), + } + }; + let sessions = session_manager + .list_all_sessions() + .await + .map_err(|error| format!("Failed to inspect Agent tasks: {error}"))?; + let session_roots = sessions + .iter() + .map(|session| (session.id.clone(), path_string(&session.working_dir))) + .collect::>(); + if project_has_active_session_run(&session_roots, &active_session_ids, &path) { + return Err("Stop the running agent before removing this project".to_string()); + } -#[tauri::command] -pub async fn agent_set_project_skills_trust( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - path: String, - trusted: bool, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let project_root = normalize_project_root(Path::new(&path))?; - let mut config = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; - apply_project_skills_trust(&mut config, &project_root, trusted)?; - save_agent_config_inner(&app_handle, &user_id, &config).map_err(|e| e.to_string())?; - Ok(project_skills_trust_status(&config, &project_root, true)) -} - -#[tauri::command] -pub async fn agent_save_project_root_order( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - paths: Vec, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - save_project_root_order_inner(&app_handle, &user_id, paths).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn agent_create_session( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - request: Option, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let request = request.unwrap_or(AgentCreateSessionRequest { - project_root: None, - title: None, - model: None, - context_limit: None, - mode: None, - mcp_server_names: None, - }); - let ( - agent_manager, - session_manager, - maple_api_session, - permission_modes, - web_tool_state, - runtime_project_root, - runtime_model, - runtime_mode, - ) = { - 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, &account_scope)?; - ( - 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), - current.project_root.clone(), - current.model.clone(), - current.mode.clone(), + let mut config = load_agent_config_inner(&state.host.paths, &self.user_id) + .map_err(|error| error.to_string())?; + apply_project_root_removal(&mut config, &path, fallback_path.as_deref())?; + // The tombstone is the only persistent removal state. Saving the fallback + // into roaming config would let this device's removal alter another + // device. Runtime/UI use the fallback immediately; startup filters the + // stale hidden default before selecting any project. + save_removed_project_roots_inner( + &state.host.paths, + &self.user_id, + &config.removed_project_roots, ) - }; + .map_err(|error| error.to_string())?; - let config = - load_agent_config_inner(&app_handle, &user_id).map_err(|error| error.to_string())?; - let root = match request.project_root.as_deref() { - Some(path) if !path.trim().is_empty() => normalize_project_root(Path::new(path))?, - _ => runtime_project_root, - }; - ensure_session_project_root_is_visible(&root, &config.removed_project_roots)?; - let title = request - .title - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_AGENT_SESSION_TITLE.to_string()); - let mode = request.mode.unwrap_or(runtime_mode); - let permission_mode = parse_user_permission_mode(&mode)?; - let model = request.model.unwrap_or(runtime_model); - let configured_mcp = normalize_mcp_servers(config.mcp_servers)?; - let selected_mcp = select_mcp_servers(&configured_mcp, request.mcp_server_names.as_deref())?; - let selected_extensions = selected_mcp - .iter() - .map(mcp_server_to_extension) - .collect::, _>>()?; - let selected_extension_keys = mcp_extension_keys(&selected_extensions); - let session = session_manager - .create_session(root.clone(), title, SessionType::User, permission_mode) - .await - .map_err(|e| format!("Failed to create Agent task: {e}"))?; + let mut runtime = state.inner.lock().await; + if let Some(current) = runtime.as_mut() { + update_runtime_project_root_after_removal( + &mut current.project_root, + &path, + fallback_path.as_deref(), + ); + } - permission_modes - .lock() - .await - .insert(session.id.clone(), permission_mode); - let setup_result: Result, String> = async { - let (agent, mut mcp_errors) = configure_session_agent( - AgentSkillsScope { - app_handle: &app_handle, - user_id: &user_id, - }, - &agent_manager, - &session_manager, - &maple_api_session, - SessionAgentConfiguration { - web_tool_state: &web_tool_state, - session: &session, - model: &model, - context_limit: request.context_limit, - mode: &mode, - primary_model_supports_vision: false, - }, - ) - .await?; - if !selected_extensions.is_empty() { - // Resolve every fallible part of restoring Maple's transient Skills client before - // Goose persists the MCP mutation. Reattachment after this point is infallible. - let skills_client = - prepare_transient_skills_client(&app_handle, &user_id, &agent, &session)?; - detach_transient_skills_client(&agent).await; - let extension_result = agent - .add_extensions_bulk(selected_extensions, &session.id) - .await; - attach_prepared_skills_client(&agent, skills_client).await; - match extension_result { - Ok(results) => { - mcp_errors.extend(mcp_connection_errors(results, &selected_extension_keys)) + Ok(config) + } + + pub(crate) async fn get_project_skills_trust( + &self, + path: String, + ) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let requested = Path::new(path.trim()); + if !requested.is_dir() { + return Ok(AgentProjectSkillsTrustStatus { + path: path_string(requested), + decision: None, + available: false, + }); + } + let project_root = normalize_project_root(requested)?; + let config = + load_agent_config_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string())?; + Ok(project_skills_trust_status(&config, &project_root, true)) + } + + pub(crate) async fn set_project_skills_trust( + &self, + path: String, + trusted: bool, + ) -> Result { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let project_root = normalize_project_root(Path::new(&path))?; + let mut config = + load_agent_config_inner(&state.host.paths, &self.user_id).map_err(|e| e.to_string())?; + apply_project_skills_trust(&mut config, &project_root, trusted)?; + save_agent_config_inner(&state.host.paths, &self.user_id, &config) + .map_err(|e| e.to_string())?; + Ok(project_skills_trust_status(&config, &project_root, true)) + } + + pub(crate) async fn save_project_root_order( + &self, + paths: Vec, + ) -> Result, String> { + let state = &self.service; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + save_project_root_order_inner(&state.host.paths, &self.user_id, paths) + .map_err(|e| e.to_string()) + } +} + +impl AgentRuntimeHandle { + pub(crate) async fn create_session( + &self, + request: Option, + ) -> Result { + Ok(self + .create_session_with_tool_context(request, None, AgentHostEventPolicy::Publish) + .await? + .detail) + } + + pub(crate) async fn create_session_with_tool_context( + &self, + request: Option, + tool_context: Option, + host_events: AgentHostEventPolicy, + ) -> Result { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let has_external_tool_context = tool_context.is_some(); + let tool_context = SharedAgentToolContext::new( + tool_context.unwrap_or_else(|| state.host.default_tool_context.clone()), + ); + let mut tool_context_installation = + PendingAgentToolContextInstallation::new(tool_context.clone()); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let request = request.unwrap_or(AgentCreateSessionRequest { + project_root: None, + title: None, + model: None, + context_limit: None, + mode: None, + mcp_server_names: None, + }); + let ( + agent_manager, + session_manager, + maple_api_session, + permission_modes, + web_tool_state, + runtime_project_root, + runtime_model, + runtime_mode, + ) = { + 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, account_scope)?; + ( + 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), + current.project_root.clone(), + current.model.clone(), + current.mode.clone(), + ) + }; + + let config = load_agent_config_inner(&state.host.paths, user_id) + .map_err(|error| error.to_string())?; + let root = match request.project_root.as_deref() { + Some(path) if !path.trim().is_empty() => normalize_project_root(Path::new(path))?, + _ => runtime_project_root, + }; + ensure_session_project_root_is_visible(&root, &config.removed_project_roots)?; + let title = request + .title + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_AGENT_SESSION_TITLE.to_string()); + let mode = request.mode.unwrap_or(runtime_mode); + let permission_mode = parse_user_permission_mode(&mode)?; + let model = request.model.unwrap_or(runtime_model); + let configured_mcp = normalize_mcp_servers(config.mcp_servers)?; + let selected_mcp = + select_mcp_servers(&configured_mcp, request.mcp_server_names.as_deref())?; + let selected_extensions = selected_mcp + .iter() + .map(mcp_server_to_extension) + .collect::, _>>()?; + let selected_extension_keys = mcp_extension_keys(&selected_extensions); + let session = session_manager + .create_session(root.clone(), title, SessionType::User, permission_mode) + .await + .map_err(|e| format!("Failed to create Agent task: {e}"))?; + + let installation_id = next_tool_context_installation_id(); + let setup_result: Result, String> = async { + let (agent, mut mcp_errors) = configure_session_agent( + AgentSkillsScope { + paths: &state.host.paths, + user_id, + }, + &agent_manager, + &session_manager, + &maple_api_session, + SessionAgentConfiguration { + web_tool_state: &web_tool_state, + session: &session, + model: &model, + context_limit: request.context_limit, + mode: &mode, + primary_model_supports_vision: false, + tool_context: &tool_context, + }, + ) + .await?; + if !selected_extensions.is_empty() { + // Resolve every fallible part of restoring Maple's transient Skills client before + // Goose persists the MCP mutation. Reattachment after this point is infallible. + let skills_client = + prepare_transient_skills_client(&state.host.paths, user_id, &agent, &session)?; + detach_transient_skills_client(&agent).await; + let extension_result = agent + .add_extensions_bulk(selected_extensions, &session.id) + .await; + attach_prepared_skills_client(&agent, skills_client).await; + match extension_result { + Ok(results) => { + mcp_errors.extend(mcp_connection_errors(results, &selected_extension_keys)) + } + Err(error) => mcp_errors.push(AgentMcpConnectionError { + name: "MCP servers".to_string(), + error: error.to_string(), + }), } - Err(error) => mcp_errors.push(AgentMcpConnectionError { - name: "MCP servers".to_string(), - error: error.to_string(), - }), } + Ok(mcp_errors) } - Ok(mcp_errors) - } - .await; - let mcp_errors = match setup_result { - Ok(mcp_errors) => mcp_errors, - Err(error) => { - permission_modes.lock().await.remove(&session.id); - if let Err(cleanup_error) = session_manager.delete_session(&session.id).await { - log::warn!( - "Failed to remove Agent task {} after setup error: {cleanup_error}", - session.id - ); + .await; + let mcp_errors = match setup_result { + Ok(mcp_errors) => mcp_errors, + Err(error) => { + if let Err(cleanup_error) = session_manager.delete_session(&session.id).await { + log::warn!( + "Failed to remove Agent task {} after setup error: {cleanup_error}", + session.id + ); + } + if let Err(cleanup_error) = + agent_manager.remove_session_if_loaded(&session.id).await + { + log::warn!( + "Failed to unload Agent task {} after setup error: {cleanup_error}", + session.id + ); + } + return Err(error); } - if let Err(cleanup_error) = agent_manager.remove_session_if_loaded(&session.id).await { - log::warn!( - "Failed to unload Agent task {} after setup error: {cleanup_error}", - session.id - ); + }; + let summary = session_summary(&session); + // Session creation must not mutate project order. Only explicit folder-add and reorder + // commands may change the persisted project list. + let detail = AgentSessionDetail { + session: summary.clone(), + timeline: Vec::new(), + mcp_errors, + }; + let tool_context_lease = has_external_tool_context.then(|| AgentToolContextLease { + service: state.clone(), + access: AgentToolContextAccess { + account_scope: Arc::clone(&self.account_scope), + session_id: Arc::from(detail.session.id.as_str()), + installation_id, + context: tool_context.clone(), + }, + }); + + // Publish the configured context only after every fallible setup await. + // Once inserted, the lease is committed and returned without yielding, + // so cancellation cannot strand a secret-bearing registry entry. + { + 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)?; + let mut modes = permission_modes.lock().await; + if let Some(replaced) = current.session_tool_contexts.insert( + session.id.clone(), + InstalledAgentToolContext { + installation_id, + context: tool_context, + owner: if has_external_tool_context { + AgentToolContextOwner::Leased + } else { + AgentToolContextOwner::Maple + }, + }, + ) { + replaced.context.revoke(); } - return Err(error); + modes.insert(session.id.clone(), permission_mode); } - }; - let summary = session_summary(&session); - // Session creation must not mutate project order. Only explicit folder-add and reorder - // commands may change the persisted project list. - let detail = AgentSessionDetail { - session: summary.clone(), - timeline: Vec::new(), - mcp_errors, - }; - emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "sessionCreated".to_string(), - session_id: Some(summary.id.clone()), - run_id: None, - item: None, - status: None, - session: Some(summary), - message: None, - }, - ); - Ok(detail) -} - -#[tauri::command] -pub async fn agent_list_sessions( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - project_root: Option, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let (session_manager, filter_root) = { - let runtime = state.inner.lock().await; - let session_manager = match runtime.as_ref() { - Some(current) => { - ensure_runtime_account(current, &account_scope)?; - Arc::clone(¤t.session_manager) - } - None => account_session_manager(&app_handle, &user_id)?, + tool_context_installation.commit(); + if host_events.publishes() { + emit_agent_event( + &state.host.events, + AgentServiceEvent::SessionCreated(summary), + ); + } + Ok(CreatedAgentSession { + detail, + tool_context_lease, + }) + } + + pub(crate) async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String> { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let (session_manager, filter_root) = { + let runtime = state.inner.lock().await; + let session_manager = match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, account_scope)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&state.host.paths, user_id)?, + }; + let filter_root = project_root + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|path| normalize_project_root(Path::new(path))) + .transpose()?; + (session_manager, filter_root) }; - let filter_root = project_root - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(|path| normalize_project_root(Path::new(path))) - .transpose()?; - (session_manager, filter_root) - }; - let mut sessions = session_manager - .list_all_sessions() - .await - .map_err(|e| format!("Failed to list Agent tasks: {e}"))? - .into_iter() - .filter(|session| { - if let Some(root) = filter_root.as_ref() { - session.working_dir == *root - } else { - true + let mut sessions = session_manager + .list_all_sessions() + .await + .map_err(|e| format!("Failed to list Agent tasks: {e}"))? + .into_iter() + .filter(|session| { + if let Some(root) = filter_root.as_ref() { + session.working_dir == *root + } else { + true + } + }) + .map(|session| session_summary(&session)) + .collect::>(); + sort_sessions_newest_first(&mut sessions); + Ok(sessions) + } + + pub(crate) async fn load_session( + &self, + session_id: String, + ) -> Result { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let session_manager = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, account_scope)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&state.host.paths, user_id)?, } + }; + let session = session_manager + .get_session(&session_id, true) + .await + .map_err(|e| format!("Failed to load Agent task: {e}"))?; + let conversation = session + .conversation + .as_ref() + .ok_or_else(|| "Agent task history was not loaded".to_string())?; + let timeline = conversation_to_timeline_items(conversation); + let mut timeline = overlay_live_timeline( + &state.live_timelines, + &session_id, + AgentPermissionRouting::Desktop, + conversation, + timeline, + ) + .await; + // Goose can persist an action-required row before Maple has registered + // its responder. Reconcile the final Desktop projection against the + // actual surface owner so another caller's request can never acquire + // actionable Desktop buttons during that gap or from stale history. + let calling_surface_active = { + let runtime = state.inner.lock().await; + runtime.as_ref().is_some_and(|current| { + current.account_scope == account_scope + && current.active_runs.values().any(|run| { + run.session_id == session_id + && run.permission_routing == AgentPermissionRouting::CallingSurface + }) + }) + }; + let pending_routes = state + .pending_permissions + .lock() + .await + .iter() + .filter(|((pending_session_id, _), _)| pending_session_id == &session_id) + .map(|((_, request_id), pending)| (request_id.clone(), pending.routing)) + .collect::>(); + reconcile_desktop_permission_items(&mut timeline, &pending_routes, calling_surface_active); + + Ok(AgentSessionDetail { + session: session_summary(&session), + timeline, + mcp_errors: Vec::new(), }) - .map(|session| session_summary(&session)) - .collect::>(); - sort_sessions_newest_first(&mut sessions); - Ok(sessions) -} + } -#[tauri::command] -pub async fn agent_load_session( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - session_id: String, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let session_manager = { - let runtime = state.inner.lock().await; - match runtime.as_ref() { - Some(current) => { - ensure_runtime_account(current, &account_scope)?; - Arc::clone(¤t.session_manager) + pub(crate) async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String> { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + let session_manager = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, account_scope)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&state.host.paths, user_id)?, } - None => account_session_manager(&app_handle, &user_id)?, + }; + let session = session_manager + .get_session(session_id.trim(), false) + .await + .map_err(|error| format!("Failed to load Agent task: {error}"))?; + let configured = normalize_mcp_servers( + load_agent_config_inner(&state.host.paths, user_id) + .map_err(|error| format!("Failed to load MCP servers: {error}"))? + .mcp_servers, + )?; + Ok(session_mcp_servers(&configured, &session)) + } + + pub(crate) async fn set_session_mcp_server_enabled( + &self, + request: AgentSetSessionMcpServerRequest, + ) -> Result, String> { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let session_id = request.session_id.trim().to_string(); + let requested_key = goose::config::extensions::name_to_key(request.name.trim()); + if session_id.is_empty() { + return Err("Agent task ID cannot be empty".to_string()); + } + if requested_key.is_empty() || maple_reserved_extension_key(&requested_key) { + return Err("That MCP server cannot be changed".to_string()); } - }; - let session = session_manager - .get_session(&session_id, true) - .await - .map_err(|e| format!("Failed to load Agent task: {e}"))?; - let conversation = session - .conversation - .as_ref() - .ok_or_else(|| "Agent task history was not loaded".to_string())?; - let timeline = conversation_to_timeline_items(conversation); - let timeline = - overlay_live_timeline(&state.live_timelines, &session_id, conversation, timeline).await; - - Ok(AgentSessionDetail { - session: session_summary(&session), - timeline, - mcp_errors: Vec::new(), - }) -} -#[tauri::command] -pub async fn agent_list_session_mcp_servers( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - session_id: String, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let session_manager = { - let runtime = state.inner.lock().await; - match runtime.as_ref() { - Some(current) => { - ensure_runtime_account(current, &account_scope)?; - Arc::clone(¤t.session_manager) + let (agent_manager, session_manager, maple_api_session) = { + 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, account_scope)?; + if has_active_session_run(¤t.active_runs, &session_id) { + return Err("Stop the running agent before changing MCP servers".to_string()); } - None => account_session_manager(&app_handle, &user_id)?, - } - }; - let session = session_manager - .get_session(session_id.trim(), false) - .await - .map_err(|error| format!("Failed to load Agent task: {error}"))?; - let configured = normalize_mcp_servers( - load_agent_config_inner(&app_handle, &user_id) - .map_err(|error| format!("Failed to load MCP servers: {error}"))? - .mcp_servers, - )?; - Ok(session_mcp_servers(&configured, &session)) -} - -#[tauri::command] -pub async fn agent_set_session_mcp_server_enabled( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - request: AgentSetSessionMcpServerRequest, -) -> Result, String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let _session_lifecycle_guard = state.session_lifecycle.lock().await; - let session_id = request.session_id.trim().to_string(); - let requested_key = goose::config::extensions::name_to_key(request.name.trim()); - if session_id.is_empty() { - return Err("Agent task ID cannot be empty".to_string()); - } - if requested_key.is_empty() || maple_reserved_extension_key(&requested_key) { - return Err("That MCP server cannot be changed".to_string()); - } - - let (agent_manager, session_manager, maple_api_session) = { - 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, &account_scope)?; - if has_active_session_run(¤t.active_runs, &session_id) { - return Err("Stop the running agent before changing MCP servers".to_string()); - } - ( - Arc::clone(¤t.agent_manager), - Arc::clone(¤t.session_manager), - Arc::clone(¤t.maple_api_session), + ( + Arc::clone(¤t.agent_manager), + Arc::clone(¤t.session_manager), + Arc::clone(¤t.maple_api_session), + ) + }; + let configured = normalize_mcp_servers( + load_agent_config_inner(&state.host.paths, user_id) + .map_err(|error| format!("Failed to load MCP servers: {error}"))? + .mcp_servers, + )?; + let session = session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to load Agent task: {error}"))?; + let session_mcp_keys = session_mcp_extension_keys(&session); + let manager_result = get_or_create_session_agent( + &agent_manager, + &maple_api_session, + &session, + RuntimeContext::default(), ) - }; - let configured = normalize_mcp_servers( - load_agent_config_inner(&app_handle, &user_id) - .map_err(|error| format!("Failed to load MCP servers: {error}"))? - .mcp_servers, - )?; - let session = session_manager - .get_session(&session_id, false) .await - .map_err(|error| format!("Failed to load Agent task: {error}"))?; - let session_mcp_keys = session_mcp_extension_keys(&session); - let manager_result = get_or_create_session_agent( - &agent_manager, - &maple_api_session, - &session, - RuntimeContext::default(), - ) - .await - .map_err(|error| format!("Failed to load Goose agent: {error}"))?; - for error in mcp_connection_errors(manager_result.extension_results, &session_mcp_keys) { - log::warn!( - "Failed to restore MCP server {}: {}", - error.name, - error.error - ); - } - let agent = manager_result.agent; - // Preflight Skills restoration before detaching the working client or changing persisted MCP - // state. Reattaching this prepared client after the mutation cannot fail. - let skills_client = prepare_transient_skills_client(&app_handle, &user_id, &agent, &session)?; - detach_transient_skills_client(&agent).await; - let active = agent.get_extension_configs().await; - let active_config = active - .iter() - .find(|config| mcp_transport_label(config).is_some() && config.key() == requested_key); - - let mutation_result: Result<(), String> = async { - if request.enabled { - if active_config.is_none() { - let server = configured - .iter() - .find(|server| { - goose::config::extensions::name_to_key(&server.name) == requested_key - }) - .ok_or_else(|| { + .map_err(|error| format!("Failed to load Goose agent: {error}"))?; + for error in mcp_connection_errors(manager_result.extension_results, &session_mcp_keys) { + log::warn!( + "Failed to restore MCP server {}: {}", + error.name, + error.error + ); + } + let agent = manager_result.agent; + // Preflight Skills restoration before detaching the working client or changing persisted MCP + // state. Reattaching this prepared client after the mutation cannot fail. + let skills_client = + prepare_transient_skills_client(&state.host.paths, user_id, &agent, &session)?; + detach_transient_skills_client(&agent).await; + let active = agent.get_extension_configs().await; + let active_config = active + .iter() + .find(|config| mcp_transport_label(config).is_some() && config.key() == requested_key); + + let mutation_result: Result<(), String> = async { + if request.enabled { + if active_config.is_none() { + let server = configured + .iter() + .find(|server| { + goose::config::extensions::name_to_key(&server.name) == requested_key + }) + .ok_or_else(|| { + format!( + "MCP server '{}' is no longer configured and cannot be enabled", + request.name.trim() + ) + })?; + let extension = mcp_server_to_extension(server)?; + agent + .add_extension(extension, &session_id) + .await + .map_err(|error| { + format!("Failed to connect MCP server '{}': {error}", server.name) + })?; + } + } else if let Some(config) = active_config { + agent + .remove_extension(&config.name(), &session_id) + .await + .map_err(|error| { format!( - "MCP server '{}' is no longer configured and cannot be enabled", + "Failed to disconnect MCP server '{}': {error}", request.name.trim() ) })?; - let extension = mcp_server_to_extension(server)?; + } else { + // A failed cold restore may already have removed the server from the + // live manager. Persist that authoritative state so the UI still gets + // a successful, durable disable operation. agent - .add_extension(extension, &session_id) + .persist_extension_state(&session_id) .await - .map_err(|error| { - format!("Failed to connect MCP server '{}': {error}", server.name) - })?; - } - } else if let Some(config) = active_config { - agent - .remove_extension(&config.name(), &session_id) - .await - .map_err(|error| { - format!( - "Failed to disconnect MCP server '{}': {error}", - request.name.trim() - ) - })?; - } else { - // A failed cold restore may already have removed the server from the - // live manager. Persist that authoritative state so the UI still gets - // a successful, durable disable operation. - agent - .persist_extension_state(&session_id) - .await - .map_err(|error| format!("Failed to save task MCP settings: {error}"))?; + .map_err(|error| format!("Failed to save task MCP settings: {error}"))?; + } + Ok(()) } - Ok(()) - } - .await; - attach_prepared_skills_client(&agent, skills_client).await; - mutation_result?; - - let refreshed = session_manager - .get_session(&session_id, false) - .await - .map_err(|error| format!("Failed to reload Agent task: {error}"))?; - Ok(session_mcp_servers(&configured, &refreshed)) -} + .await; + attach_prepared_skills_client(&agent, skills_client).await; + mutation_result?; -#[tauri::command] -pub async fn agent_delete_session( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - session_id: String, -) -> Result<(), String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let session_id = session_id.trim().to_string(); - if session_id.is_empty() { - return Err("Agent task ID cannot be empty".to_string()); - } + let refreshed = session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to reload Agent task: {error}"))?; + Ok(session_mcp_servers(&configured, &refreshed)) + } + + pub(crate) async fn delete_session(&self, session_id: String) -> Result<(), String> { + self.delete_session_inner(session_id, true).await + } + + /// Remove a session that an adapter failed to publish. This cleanup path is + /// intentionally available while the service is draining. + pub(crate) async fn discard_session_during_cleanup( + &self, + session_id: String, + ) -> Result<(), String> { + self.delete_session_inner(session_id, false).await + } + + async fn delete_session_inner( + &self, + session_id: String, + require_admission: bool, + ) -> Result<(), String> { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + if require_admission { + self.ensure_accepting_new_work()?; + } + let session_id = session_id.trim().to_string(); + if session_id.is_empty() { + return Err("Agent task ID cannot be empty".to_string()); + } - let _session_lifecycle_guard = state.session_lifecycle.lock().await; - let (agent_manager, session_manager, permission_modes, web_tool_state) = { - let runtime = state.inner.lock().await; - match runtime.as_ref() { - Some(current) => { - ensure_runtime_account(current, &account_scope)?; - if has_active_session_run(¤t.active_runs, &session_id) { - return Err("Stop the running agent before deleting this task".to_string()); + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let (agent_manager, session_manager, permission_modes, web_tool_state) = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, account_scope)?; + if has_active_session_run(¤t.active_runs, &session_id) { + return Err("Stop the running agent before deleting this task".to_string()); + } + ( + Some(Arc::clone(¤t.agent_manager)), + Arc::clone(¤t.session_manager), + Some(Arc::clone(¤t.permission_modes)), + Some(Arc::clone(¤t.web_tool_state)), + ) } - ( - Some(Arc::clone(¤t.agent_manager)), - Arc::clone(¤t.session_manager), - Some(Arc::clone(¤t.permission_modes)), - Some(Arc::clone(¤t.web_tool_state)), - ) + None => ( + None, + account_session_manager(&state.host.paths, user_id)?, + None, + None, + ), } - None => ( - None, - account_session_manager(&app_handle, &user_id)?, - None, - None, - ), - } - }; + }; - delete_persisted_agent_session( - session_manager.as_ref(), - &state.pending_permissions, - &state.live_timelines, - web_tool_state.as_deref(), - &session_id, - ) - .await?; - if let Some(agent_manager) = agent_manager { - if let Err(error) = agent_manager.remove_session_if_loaded(&session_id).await { - log::warn!( - "Deleted Goose session {session_id}, but failed to unload its agent: {error}" - ); + delete_persisted_agent_session( + session_manager.as_ref(), + &state.pending_permissions, + &state.live_timelines, + web_tool_state.as_deref(), + &session_id, + ) + .await?; + if let Some(agent_manager) = agent_manager { + if let Err(error) = agent_manager.remove_session_if_loaded(&session_id).await { + log::warn!( + "Deleted Goose session {session_id}, but failed to unload its agent: {error}" + ); + } + } + if let Some(permission_modes) = permission_modes { + permission_modes.lock().await.remove(&session_id); + } + let removed_tool_context = { + let mut runtime = state.inner.lock().await; + if let Some(current) = runtime.as_mut() { + ensure_runtime_account(current, account_scope)?; + current.session_tool_contexts.remove(&session_id) + } else { + None + } + }; + if let Some(installed) = removed_tool_context { + installed.context.revoke(); } - } - if let Some(permission_modes) = permission_modes { - permission_modes.lock().await.remove(&session_id); - } - Ok(()) + Ok(()) + } } async fn delete_persisted_agent_session( @@ -1700,6 +2334,7 @@ async fn finalize_cancelled_agent_turn( live_timelines: &LiveTimelines, web_tool_state: &WebToolState, session_id: &str, + routing: AgentPermissionRouting, user_message: &Message, cancelled_permission_ids: &HashSet, ) -> Result<(), String> { @@ -1740,7 +2375,10 @@ async fn finalize_cancelled_agent_turn( // Goose's persisted conversation is the committed cancellation boundary. // Drop Maple's speculative event suffix so reloads project only that history. - live_timelines.lock().await.remove(session_id); + { + let mut timelines = live_timelines.lock().await; + remove_live_timeline_for_routing(&mut timelines, session_id, routing); + } // Search provenance is an in-memory Maple permission convenience, not // Goose history. Reset it rather than letting a discarded search result // authorize a later open_url call. A cold session already starts empty. @@ -1886,665 +2524,933 @@ fn is_goose_declined_tool_response(response: &goose::conversation::message::Tool }) } -#[tauri::command] -pub async fn agent_send_message( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - request: AgentSendMessageRequest, -) -> 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; - ensure_account_generation(&state, &account_scope, generation).await?; - let text = request.text.trim().to_string(); - if text.is_empty() { - return Err("Prompt cannot be empty".to_string()); - } - - let session_lifecycle_guard = state.session_lifecycle.lock().await; - let run_id = next_run_id(); - let cancel_token = CancellationToken::new(); - let prompt_title = session_title_from_prompt(&text); - let web_permission_context = WebPermissionContext::from_user_prompt(&text); - let user_message = Message::user().with_text(text).with_generated_id(); - let ( - agent_manager, - session_manager, - maple_api_session, - permission_modes, - web_tool_state, - model, - mode, - ) = { - 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, &account_scope)?; - ( - 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), - request - .model - .clone() - .unwrap_or_else(|| current.model.clone()), - request.mode.clone().unwrap_or_else(|| current.mode.clone()), +impl AgentRuntimeHandle { + pub(crate) async fn send_message( + &self, + request: AgentSendMessageRequest, + ) -> Result { + self.send_message_inner( + request, + None, + None, + AgentHostEventPolicy::Publish, + AgentPermissionRouting::Desktop, ) - }; - let requested_permission_mode = parse_user_permission_mode(&mode)?; - - let user_item = message_to_timeline_items(&user_message, false) - .into_iter() - .next() - .ok_or_else(|| "Failed to create user timeline item".to_string())?; - let live_timelines = Arc::clone(&state.live_timelines); - - // Claim the session before changing its title, provider, mode, or - // extensions. A duplicate send must not mutate an Agent that is already - // serving another run. - agent_manager - .try_register_cancel_token(&request.session_id, cancel_token.clone()) .await - .map_err(|e| format!("Agent task is already running: {e}"))?; - - // A rejected or delayed send must not be able to change a live policy that - // the mode command already made authoritative. Seed only sessions that do - // not yet have runtime policy state, after Goose grants this run its claim. - let (permission_mode, seeded_permission_mode) = { - let mut modes = permission_modes.lock().await; - select_session_permission_mode(&mut modes, &request.session_id, requested_permission_mode) - }; - let effective_mode = permission_mode.to_string(); + } - let setup_result: Result<(Arc, Vec), String> = async { - let mut session = session_manager - .get_session(&request.session_id, true) - .await - .map_err(|e| format!("Failed to load Agent task: {e}"))?; - validate_session_model_lock( - session.message_count, - session - .model_config - .as_ref() - .map(|model| model.model_name.as_str()), - &model, - )?; - let should_name_from_prompt = should_name_session_from_prompt(&session); - if should_name_from_prompt { - session_manager - .update(&session.id) - .system_generated_name(prompt_title) - .apply() - .await - .map_err(|e| format!("Failed to name Agent task: {e}"))?; - session = session_manager - .get_session(&session.id, false) - .await - .map_err(|e| format!("Failed to load named Agent task: {e}"))?; - emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "sessionUpdated".to_string(), - session_id: Some(session.id.clone()), - run_id: Some(run_id.clone()), - item: None, - status: None, - session: Some(session_summary(&session)), - message: None, - }, - ); - } - let (agent, mcp_errors) = configure_session_agent( - AgentSkillsScope { - app_handle: &app_handle, - user_id: &user_id, - }, - &agent_manager, - &session_manager, - &maple_api_session, - SessionAgentConfiguration { - web_tool_state: &web_tool_state, - session: &session, - model: &model, - context_limit: request.context_limit, - mode: &effective_mode, - primary_model_supports_vision: request.vision_capable, - }, + pub(crate) async fn send_message_with_tool_context( + &self, + request: AgentSendMessageRequest, + access: AgentToolContextAccess, + surface_lifetime: CancellationToken, + host_events: AgentHostEventPolicy, + ) -> Result { + self.send_message_inner( + request, + Some(access), + Some(surface_lifetime), + host_events, + AgentPermissionRouting::CallingSurface, ) - .await?; - Ok((agent, mcp_errors)) + .await } - .await; - let (agent, mcp_errors) = match setup_result { - Ok(setup) => setup, - Err(error) => { - if seeded_permission_mode { - permission_modes.lock().await.remove(&request.session_id); - } - agent_manager - .unregister_cancel_token(&request.session_id) - .await; - return Err(error); + + async fn send_message_inner( + &self, + request: AgentSendMessageRequest, + tool_context_access: Option, + surface_lifetime: Option, + host_events: AgentHostEventPolicy, + permission_routing: AgentPermissionRouting, + ) -> Result { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let text = request.text.trim().to_string(); + if text.is_empty() { + return Err("Prompt cannot be empty".to_string()); } - }; - if !mcp_errors.is_empty() { - emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "error".to_string(), - session_id: None, - run_id: Some(run_id.clone()), - item: None, - status: None, - session: None, - message: Some(format_mcp_connection_errors(&mcp_errors)), - }, - ); - } - let app_handle_for_task = app_handle.clone(); - let state_inner = Arc::clone(&state.inner); - let session_lifecycle = Arc::clone(&state.session_lifecycle); - let pending_permissions = Arc::clone(&state.pending_permissions); - let session_id = request.session_id.clone(); - let task_run_id = run_id.clone(); - let task_agent_manager = Arc::clone(&agent_manager); - let task_session_manager = Arc::clone(&session_manager); - let task_permission_modes = Arc::clone(&permission_modes); - let task_web_tool_state = Arc::clone(&web_tool_state); - let task_user_message = user_message.clone(); - let task_cancel_token = cancel_token.clone(); - 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 task = tauri::async_runtime::spawn(async move { - let should_run = tokio::select! { - biased; - _ = task_cancel_token.cancelled() => false, - start = start_rx => start.is_ok(), - }; - let result = if should_run { - provider::with_run_cancellation( - task_cancel_token.clone(), - run_agent_prompt(AgentPromptRun { - app_handle: app_handle_for_task.clone(), - agent, - session_manager: Arc::clone(&task_session_manager), - live_timelines: live_timelines.clone(), - session_id: session_id.clone(), - run_id: task_run_id.clone(), - user_message: task_user_message.clone(), - permission_modes: task_permission_modes, - web_tool_state: Arc::clone(&task_web_tool_state), - web_permission_context, - cancel_token: task_cancel_token.clone(), - pending_permissions, - cancelled_permission_ids: Arc::clone(&task_cancelled_permission_ids), - }), + let session_lifecycle_guard = state.session_lifecycle.lock().await; + let run_id = next_run_id(); + let (run_events, run_events_rx) = AgentRunEventPublisher::new( + state.host.events.clone(), + request.session_id.clone(), + run_id.clone(), + host_events, + ); + let cancel_token = surface_lifetime + .as_ref() + .map(CancellationToken::child_token) + .unwrap_or_default(); + if cancel_token.is_cancelled() { + return Err("Agent surface closed before the run could start".to_string()); + } + let prompt_title = session_title_from_prompt(&text); + let web_permission_context = WebPermissionContext::from_user_prompt(&text); + let user_message = Message::user().with_text(text).with_generated_id(); + let ( + agent_manager, + session_manager, + maple_api_session, + permission_modes, + web_tool_state, + model, + mode, + ) = { + 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, account_scope)?; + ( + 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), + request + .model + .clone() + .unwrap_or_else(|| current.model.clone()), + request.mode.clone().unwrap_or_else(|| current.mode.clone()), ) - .await - } else { - Ok(AgentPromptOutcome::default()) }; + let requested_permission_mode = parse_user_permission_mode(&mode)?; - // Keep deletion serialized until every terminal write and event for - // this run has completed. The active-run entry stays visible while - // the cleanup is in progress, so deletion continues to reject it. - let _session_lifecycle_guard = session_lifecycle.lock().await; - // Completion and cancellation linearize under the same lock used by - // agent_cancel_run. Whichever side acquires it first owns the terminal - // result, so Stop cannot succeed against an already-settled run. - let run_was_cancelled = !should_run || task_cancel_token.is_cancelled(); - let cancelled_permission_ids = task_cancelled_permission_ids.lock().await.clone(); - let result = if run_was_cancelled { - finalize_cancelled_agent_turn( - task_session_manager.as_ref(), - &live_timelines, - task_web_tool_state.as_ref(), - &session_id, - &task_user_message, - &cancelled_permission_ids, - ) + let user_item = message_to_timeline_items(&user_message, false) + .into_iter() + .next() + .ok_or_else(|| "Failed to create user timeline item".to_string())?; + let live_timelines = Arc::clone(&state.live_timelines); + + // Claim the session before changing its title, provider, mode, or + // extensions. A duplicate send must not mutate an Agent that is already + // serving another run. + agent_manager + .try_register_cancel_token(&request.session_id, cancel_token.clone()) .await - .map(|_| AgentPromptOutcome::default()) - } else { - result + .map_err(|e| format!("Agent task is already running: {e}"))?; + + // A rejected or delayed send must not be able to change a live policy that + // the mode command already made authoritative. Seed only sessions that do + // not yet have runtime policy state, after Goose grants this run its claim. + let (permission_mode, seeded_permission_mode) = { + let mut modes = permission_modes.lock().await; + select_session_permission_mode( + &mut modes, + &request.session_id, + requested_permission_mode, + ) }; - task_agent_manager - .unregister_cancel_token(&session_id) - .await; - if !run_was_cancelled { - if let Ok(outcome) = &result { - let mut timelines = live_timelines.lock().await; - apply_successful_prompt_outcome(&mut timelines, &session_id, outcome); - } - } + let effective_mode = permission_mode.to_string(); - let (status, message) = match result { - Ok(_) if run_was_cancelled => ("cancelled", None), - Ok(_) => ("completed", None), - Err(error) => ("failed", Some(error)), - }; - if let Some(error) = message.as_ref() { - let item = error_item(error.clone()); - { - let mut timelines = live_timelines.lock().await; - apply_failed_prompt_outcome(&mut timelines, &session_id, item.clone()); + let setup_result: Result< + ( + Arc, + Vec, + SharedAgentToolContext, + ), + String, + > = async { + // External surfaces present an opaque exact-match capability. Check + // it before any persisted-session work so deletion that won the + // session lifecycle race is reported as an expired surface task. + let external_tool_context = if tool_context_access.is_some() { + 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)?; + Some(resolve_session_tool_context( + &mut current.session_tool_contexts, + account_scope, + &request.session_id, + tool_context_access.as_ref(), + &state.host.default_tool_context, + )?) + } else { + None + }; + let mut session = session_manager + .get_session(&request.session_id, true) + .await + .map_err(|e| format!("Failed to load Agent task: {e}"))?; + validate_session_model_lock( + session.message_count, + session + .model_config + .as_ref() + .map(|model| model.model_name.as_str()), + &model, + )?; + let tool_context = match external_tool_context { + Some(context) => context, + None => { + 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)?; + resolve_session_tool_context( + &mut current.session_tool_contexts, + account_scope, + &request.session_id, + None, + &state.host.default_tool_context, + )? + } + }; + let should_name_from_prompt = should_name_session_from_prompt(&session); + if should_name_from_prompt { + session_manager + .update(&session.id) + .system_generated_name(prompt_title) + .apply() + .await + .map_err(|e| format!("Failed to name Agent task: {e}"))?; + session = session_manager + .get_session(&session.id, false) + .await + .map_err(|e| format!("Failed to load named Agent task: {e}"))?; + run_events + .publish(AgentRunEvent::SessionUpdated(session_summary(&session))) + .await; } - emit_agent_event( - &app_handle_for_task, - AgentEventEnvelope { - event_type: "error".to_string(), - session_id: Some(session_id.clone()), - run_id: Some(task_run_id.clone()), - item: Some(item), - status: None, - session: None, - message: None, + let (agent, mcp_errors) = configure_session_agent( + AgentSkillsScope { + paths: &state.host.paths, + user_id, }, - ); - } - emit_agent_event( - &app_handle_for_task, - AgentEventEnvelope { - event_type: "runFinished".to_string(), - session_id: Some(session_id), - run_id: Some(task_run_id.clone()), - item: None, - status: None, - session: None, - message: Some(status.to_string()), - }, - ); - // 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. - let mut runtime = state_inner.lock().await; - if let Some(current) = runtime.as_mut() { - current.active_runs.remove(&task_run_id); + &agent_manager, + &session_manager, + &maple_api_session, + SessionAgentConfiguration { + web_tool_state: &web_tool_state, + session: &session, + model: &model, + context_limit: request.context_limit, + mode: &effective_mode, + primary_model_supports_vision: request.vision_capable, + tool_context: &tool_context, + }, + ) + .await?; + Ok((agent, mcp_errors, tool_context)) } - }); - - let mut task = Some(task); - let insertion_error = { - let mut runtime = state.inner.lock().await; - match runtime.as_mut() { - None => Some("Agent runtime is not running".to_string()), - Some(current) => match ensure_runtime_account(current, &account_scope) { - Err(error) => Some(error), - Ok(()) => { - current.active_runs.insert( - run_id.clone(), - ActiveAgentRun { - token: cancel_token.clone(), - session_id: request.session_id.clone(), - cancelled_permission_ids: Arc::clone(&cancelled_permission_ids), - task_handle: task.take().expect("task handle must be available"), - }, - ); - None + .await; + let (agent, mcp_errors, tool_context) = match setup_result { + Ok(setup) => setup, + Err(error) => { + if seeded_permission_mode { + permission_modes.lock().await.remove(&request.session_id); } - }, - } - }; - if let Some(error) = insertion_error { - let task = task.expect("failed insertion must retain task handle"); - task.abort(); - let _ = task.await; - agent_manager - .unregister_cancel_token(&request.session_id) - .await; - return Err(error); - } - emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "runStarted".to_string(), - session_id: Some(request.session_id.clone()), - run_id: Some(run_id.clone()), - item: None, - status: None, - session: None, - message: None, - }, - ); - - record_and_emit_timeline_item( - &app_handle, - &state.live_timelines, - &request.session_id, - &run_id, - user_item.clone(), - ) - .await; - let _ = start_tx.send(()); - // Keep the session claimed until the optimistic timeline item and start - // signal are ordered. A cancellation cleanup must not finish and then be - // followed by this send path re-appending the cancelled prompt. - drop(session_lifecycle_guard); - - Ok(AgentRunResponse { run_id }) -} - -#[tauri::command] -pub async fn agent_cancel_run( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - run_id: String, -) -> Result<(), String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - // Order permission updates before the worker's authoritative reload and - // terminal event. If the worker settled first, its active-run entry will - // already be gone by the time this command inspects it. - let _session_lifecycle_guard = state.session_lifecycle.lock().await; - let (agent_manager, session_id, cancel_token, cancelled_permission_ids) = { - let runtime = state.inner.lock().await; - let Some(current) = runtime.as_ref() else { - return Ok(()); - }; - ensure_runtime_account(current, &account_scope)?; - let Some(active_run) = current.active_runs.get(&run_id) else { - return Ok(()); + agent_manager + .unregister_cancel_token(&request.session_id) + .await; + return Err(error); + } }; - ( - Arc::clone(¤t.agent_manager), - active_run.session_id.clone(), - active_run.token.clone(), - Arc::clone(&active_run.cancelled_permission_ids), - ) - }; - cancel_token.cancel(); - let cancelled_permissions = cancel_pending_permissions_for_sessions( - &agent_manager, - &state.pending_permissions, - std::slice::from_ref(&session_id), - ) - .await; - cancelled_permission_ids.lock().await.extend( - cancelled_permissions - .iter() - .map(|(request_id, _)| request_id.clone()), - ); - for (request_id, session_id) in cancelled_permissions { - if let Some(item) = update_live_permission_status( - &state.live_timelines, - &session_id, - &request_id, - "cancelled", - ) - .await - { - emit_timeline_item(&app_handle, &session_id, &run_id, item); + if !mcp_errors.is_empty() { + run_events + .publish(AgentRunEvent::SetupWarning(format_mcp_connection_errors( + &mcp_errors, + ))) + .await; + } + if cancel_token.is_cancelled() { + if seeded_permission_mode { + permission_modes.lock().await.remove(&request.session_id); + } + agent_manager + .unregister_cancel_token(&request.session_id) + .await; + return Err("Agent surface closed before the run could start".to_string()); } - } - Ok(()) -} - -#[tauri::command] -pub async fn agent_set_permission_mode( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - request: AgentPermissionModeRequest, -) -> Result<(), String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - - let session_id = request.session_id.trim().to_string(); - if session_id.is_empty() { - return Err("Agent permission mode update requires a task ID".to_string()); - } - let goose_mode = parse_user_permission_mode(&request.mode)?; - let (agent_manager, session_manager, maple_api_session, permission_modes) = { - 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, &account_scope)?; - ( - Arc::clone(¤t.agent_manager), - Arc::clone(¤t.session_manager), - Arc::clone(¤t.maple_api_session), - Arc::clone(¤t.permission_modes), - ) - }; - // Restrictive transitions take effect before any fallible Goose or disk - // work. Otherwise the selector could say Read only while a still-live Auto - // policy approves the next write. If setup fails, restore the previous - // policy so the command and optimistic UI can roll back consistently. - let previous_restrictive_mode = if goose_mode == GooseMode::SmartApprove { - permission_modes - .lock() - .await - .insert(session_id.clone(), goose_mode) - } else { - None - }; - let update_result: Result, String> = async { - let session = session_manager - .get_session(&session_id, false) - .await - .map_err(|error| format!("Failed to load Agent task: {error}"))?; - let agent = get_or_create_session_agent( - &agent_manager, - &maple_api_session, - &session, - RuntimeContext::default(), - ) - .await - .map_err(|error| format!("Failed to resolve Goose agent for mode update: {error}"))? - .agent; - agent - .update_goose_mode(GOOSE_PERMISSION_ROUTING_MODE, &session_id) - .await - .map_err(|error| format!("Failed to update Goose mode: {error}"))?; - // update_goose_mode already persists SmartApprove, which is both our - // internal Goose routing mode and the user-facing Read-only mode. Auto - // is Maple-owned, so only that case needs a second persistence step. - // Keeping Read-only to one write avoids a failed duplicate write - // leaving the persisted session stricter than the live Maple policy. - if goose_mode == GooseMode::Auto { - session_manager - .update(&session_id) - .goose_mode(goose_mode) - .apply() + let task_events = run_events.clone(); + let state_inner = Arc::clone(&state.inner); + let session_lifecycle = Arc::clone(&state.session_lifecycle); + let task_pending_permissions = Arc::clone(&state.pending_permissions); + let session_id = request.session_id.clone(); + let task_run_id = run_id.clone(); + let task_agent_manager = Arc::clone(&agent_manager); + let task_session_manager = Arc::clone(&session_manager); + let task_permission_modes = Arc::clone(&permission_modes); + let task_web_tool_state = Arc::clone(&web_tool_state); + let task_user_message = user_message.clone(); + let task_cancel_token = cancel_token.clone(); + let task_agent = Arc::clone(&agent); + let active_agent = Arc::clone(&agent); + let cancelled_permission_ids = Arc::new(Mutex::new(HashSet::new())); + let task_cancelled_permission_ids = Arc::clone(&cancelled_permission_ids); + let task_issued_permission_ids = Arc::new(Mutex::new(HashSet::new())); + let (start_tx, start_rx) = oneshot::channel(); + let (terminal_tx, terminal_rx) = watch::channel(None); + let task = tokio::spawn(async move { + let should_run = tokio::select! { + biased; + _ = task_cancel_token.cancelled() => false, + start = start_rx => start.is_ok(), + }; + let result = if should_run { + provider::with_run_cancellation( + task_cancel_token.clone(), + run_agent_prompt(AgentPromptRun { + events: task_events.clone(), + agent: Arc::clone(&task_agent), + session_manager: Arc::clone(&task_session_manager), + live_timelines: live_timelines.clone(), + session_id: session_id.clone(), + user_message: task_user_message.clone(), + permission_modes: task_permission_modes, + web_tool_state: Arc::clone(&task_web_tool_state), + web_permission_context, + cancel_token: task_cancel_token.clone(), + pending_permissions: Arc::clone(&task_pending_permissions), + issued_permission_ids: task_issued_permission_ids, + cancelled_permission_ids: Arc::clone(&task_cancelled_permission_ids), + run_id: task_run_id.clone(), + permission_routing, + }), + ) .await - .map_err(|error| format!("Failed to persist Agent permission mode: {error}"))?; - } - Ok(agent) - } - .await; - let agent = match update_result { - Ok(agent) => agent, - Err(error) => { - if goose_mode == GooseMode::SmartApprove { - let mut modes = permission_modes.lock().await; - match previous_restrictive_mode { - Some(previous) => { - modes.insert(session_id.clone(), previous); - } - None => { - modes.remove(&session_id); + } else { + Ok(AgentPromptOutcome::default()) + }; + + // Keep deletion serialized until every terminal write and event for + // this run has completed. The active-run entry stays visible while + // the cleanup is in progress, so deletion continues to reject it. + let _session_lifecycle_guard = session_lifecycle.lock().await; + // Completion and cancellation linearize under the same lock used by + // agent_cancel_run. Whichever side acquires it first owns the terminal + // result, so Stop cannot succeed against an already-settled run. + let run_was_cancelled = !should_run || task_cancel_token.is_cancelled(); + let terminal_permissions = cancel_pending_permissions_for_runs( + &task_pending_permissions, + std::slice::from_ref(&task_run_id), + &HashMap::from([(task_run_id.clone(), Arc::clone(&task_agent))]), + ) + .await; + if !terminal_permissions.is_empty() { + task_cancelled_permission_ids.lock().await.extend( + terminal_permissions + .iter() + .map(|((_, request_id), _)| request_id.clone()), + ); + for ((permission_session_id, request_id), _) in terminal_permissions { + if let Some(item) = update_live_permission_status( + &live_timelines, + &permission_session_id, + permission_routing, + &request_id, + "cancelled", + ) + .await + { + task_events.publish(AgentRunEvent::TimelineItem(item)).await; } } } + let cancelled_permission_ids = task_cancelled_permission_ids.lock().await.clone(); + let result = if run_was_cancelled { + finalize_cancelled_agent_turn( + task_session_manager.as_ref(), + &live_timelines, + task_web_tool_state.as_ref(), + &session_id, + permission_routing, + &task_user_message, + &cancelled_permission_ids, + ) + .await + .map(|_| AgentPromptOutcome::default()) + } else { + result + }; + task_agent_manager + .unregister_cancel_token(&session_id) + .await; + if !run_was_cancelled { + if let Ok(outcome) = &result { + let mut timelines = live_timelines.lock().await; + apply_successful_prompt_outcome( + &mut timelines, + &session_id, + permission_routing, + outcome, + ); + } + } + + let (status, message) = match result { + Ok(_) if run_was_cancelled => ("cancelled", None), + Ok(_) => ("completed", None), + Err(error) => ("failed", Some(error)), + }; + if let Some(error) = message.as_ref() { + let item = error_item(error.clone()); + { + let mut timelines = live_timelines.lock().await; + apply_failed_prompt_outcome( + &mut timelines, + &session_id, + permission_routing, + item.clone(), + ); + } + task_events.publish(AgentRunEvent::Error(item)).await; + } + // 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, + }; + task_events.publish(AgentRunEvent::Finished(terminal)).await; + 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. + let mut runtime = state_inner.lock().await; + if let Some(current) = runtime.as_mut() { + current.active_runs.remove(&task_run_id); + } + }); + + let mut task = Some(task); + let insertion_error = { + let mut runtime = state.inner.lock().await; + match runtime.as_mut() { + None => Some("Agent runtime is not running".to_string()), + Some(current) => match ensure_runtime_account(current, account_scope) { + Err(error) => Some(error), + Ok(()) => { + current.active_runs.insert( + run_id.clone(), + ActiveAgentRun { + agent: active_agent, + permission_routing, + token: cancel_token.clone(), + tool_context: tool_context.clone(), + session_id: request.session_id.clone(), + events: run_events.clone(), + cancelled_permission_ids: Arc::clone(&cancelled_permission_ids), + task_handle: task.take().expect("task handle must be available"), + }, + ); + None + } + }, + } + }; + if let Some(error) = insertion_error { + let task = task.expect("failed insertion must retain task handle"); + task.abort(); + let _ = task.await; + agent_manager + .unregister_cancel_token(&request.session_id) + .await; return Err(error); } - }; - if goose_mode == GooseMode::Auto { - permission_modes - .lock() - .await - .insert(session_id.clone(), goose_mode); + run_events.publish(AgentRunEvent::Started).await; + + record_and_emit_timeline_item( + &run_events, + &state.live_timelines, + &request.session_id, + permission_routing, + user_item.clone(), + ) + .await; + let _ = start_tx.send(()); + // Keep the session claimed until the optimistic timeline item and start + // signal are ordered. A cancellation cleanup must not finish and then be + // followed by this send path re-appending the cancelled prompt. + drop(session_lifecycle_guard); + + let permission_responder = + matches!(permission_routing, AgentPermissionRouting::CallingSurface).then(|| { + AgentRunPermissionResponder { + agent: self.clone(), + session_id: Arc::from(request.session_id.as_str()), + run_id: Arc::from(run_id.as_str()), + } + }); + let cancellation = matches!(permission_routing, AgentPermissionRouting::CallingSurface) + .then(|| AgentRunCancellation { + agent: self.clone(), + session_id: Arc::from(request.session_id.as_str()), + run_id: Arc::from(run_id.as_str()), + routing: permission_routing, + }); + Ok(AgentRunHandle { + run_id, + events: run_events_rx, + terminal: terminal_rx, + event_overflowed: run_events.overflow_flag(), + permission_responder, + cancellation, + }) } - { - 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.mode = request.mode.clone(); + + pub(crate) async fn cancel_desktop_run(&self, run_id: String) -> Result<(), String> { + self.cancel_run_scoped(&run_id, None, AgentPermissionRouting::Desktop) + .await } - if goose_mode == GooseMode::Auto { - let request_ids = { - let mut pending = state.pending_permissions.lock().await; - let request_ids = pending - .keys() - .filter(|(pending_session_id, _)| pending_session_id == &session_id) - .map(|(_, request_id)| request_id.clone()) - .collect::>(); - for request_id in &request_ids { - pending.remove(&(session_id.clone(), request_id.clone())); - } - request_ids + async fn cancel_run_scoped( + &self, + run_id: &str, + expected_session_id: Option<&str>, + expected_routing: AgentPermissionRouting, + ) -> Result<(), String> { + let state = &self.service; + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + // Order permission updates before the worker's authoritative reload and + // terminal event. If the worker settled first, its active-run entry will + // already be gone by the time this command inspects it. + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let (agent, cancel_token, tool_context, run_events, cancelled_permission_ids) = { + let runtime = state.inner.lock().await; + let Some(current) = runtime.as_ref() else { + return Ok(()); + }; + ensure_runtime_account(current, account_scope)?; + let Some(active_run) = current.active_runs.get(run_id) else { + return Ok(()); + }; + validate_run_cancellation_scope( + active_run.session_id.as_str(), + active_run.permission_routing, + expected_session_id, + expected_routing, + )?; + ( + Arc::clone(&active_run.agent), + active_run.token.clone(), + active_run.tool_context.clone(), + active_run.events.clone(), + Arc::clone(&active_run.cancelled_permission_ids), + ) }; - for request_id in request_ids { - deliver_tool_permission(&agent, request_id.clone(), Permission::AllowOnce).await; + tool_context.cancel_run(&cancel_token); + let run_id = run_id.to_string(); + let cancelled_permissions = cancel_pending_permissions_for_runs( + &state.pending_permissions, + std::slice::from_ref(&run_id), + &HashMap::from([(run_id.clone(), agent)]), + ) + .await; + cancelled_permission_ids.lock().await.extend( + cancelled_permissions + .iter() + .map(|((_, request_id), _)| request_id.clone()), + ); + for ((session_id, request_id), _) in cancelled_permissions { if let Some(item) = update_live_permission_status( &state.live_timelines, &session_id, + expected_routing, &request_id, - "allow_once", + "cancelled", ) .await { - emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "timelineItem".to_string(), - session_id: Some(session_id.clone()), - run_id: None, - item: Some(item), - status: None, - session: None, - message: None, - }, - ); + run_events.publish(AgentRunEvent::TimelineItem(item)).await; } } + Ok(()) } - // The policy is already committed at this point. A best-effort refresh - // must not report failure to the selector and make it roll back to a mode - // that is no longer authoritative. - match session_manager.get_session(&session_id, false).await { + pub(crate) async fn set_permission_mode( + &self, + request: AgentPermissionModeRequest, + ) -> Result<(), String> { + let state = &self.service; + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("Agent permission mode update requires a task ID".to_string()); + } + let goose_mode = parse_user_permission_mode(&request.mode)?; + let (agent_manager, session_manager, maple_api_session, permission_modes, active_agent) = { + 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, account_scope)?; + if current.active_runs.values().any(|run| { + run.session_id == session_id + && run.permission_routing == AgentPermissionRouting::CallingSurface + }) || current + .session_tool_contexts + .get(&session_id) + .is_some_and(|installed| installed.owner == AgentToolContextOwner::Leased) + { + return Err("This Agent task is controlled by another Agent surface".to_string()); + } + ( + Arc::clone(¤t.agent_manager), + Arc::clone(¤t.session_manager), + Arc::clone(¤t.maple_api_session), + Arc::clone(¤t.permission_modes), + current + .active_runs + .values() + .find(|run| { + run.session_id == session_id + && run.permission_routing == AgentPermissionRouting::Desktop + }) + .map(|run| Arc::clone(&run.agent)), + ) + }; + + // Restrictive transitions take effect before any fallible Goose or disk + // work. Otherwise the selector could say Read only while a still-live Auto + // policy approves the next write. If setup fails, restore the previous + // policy so the command and optimistic UI can roll back consistently. + let previous_restrictive_mode = if goose_mode == GooseMode::SmartApprove { + permission_modes + .lock() + .await + .insert(session_id.clone(), goose_mode) + } else { + None + }; + let update_result: Result, String> = async { + let session = session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to load Agent task: {error}"))?; + let agent = match active_agent { + Some(agent) => agent, + None => { + get_or_create_session_agent( + &agent_manager, + &maple_api_session, + &session, + RuntimeContext::default(), + ) + .await + .map_err(|error| { + format!("Failed to resolve Goose agent for mode update: {error}") + })? + .agent + } + }; + agent + .update_goose_mode(GOOSE_PERMISSION_ROUTING_MODE, &session_id) + .await + .map_err(|error| format!("Failed to update Goose mode: {error}"))?; + // update_goose_mode already persists SmartApprove, which is both our + // internal Goose routing mode and the user-facing Read-only mode. Auto + // is Maple-owned, so only that case needs a second persistence step. + // Keeping Read-only to one write avoids a failed duplicate write + // leaving the persisted session stricter than the live Maple policy. + if goose_mode == GooseMode::Auto { + session_manager + .update(&session_id) + .goose_mode(goose_mode) + .apply() + .await + .map_err(|error| format!("Failed to persist Agent permission mode: {error}"))?; + } + Ok(agent) + } + .await; + let agent = match update_result { + Ok(agent) => agent, + Err(error) => { + if goose_mode == GooseMode::SmartApprove { + let mut modes = permission_modes.lock().await; + match previous_restrictive_mode { + Some(previous) => { + modes.insert(session_id.clone(), previous); + } + None => { + modes.remove(&session_id); + } + } + } + return Err(error); + } + }; + if goose_mode == GooseMode::Auto { + permission_modes + .lock() + .await + .insert(session_id.clone(), goose_mode); + } + { + 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.mode = request.mode.clone(); + } + + if goose_mode == GooseMode::Auto { + let request_ids = { + let mut pending = state.pending_permissions.lock().await; + let request_ids = pending + .iter() + .filter(|((pending_session_id, _), request)| { + pending_session_id == &session_id + && request.routing == AgentPermissionRouting::Desktop + }) + .map(|((_, request_id), _)| request_id.clone()) + .collect::>(); + for request_id in &request_ids { + pending.remove(&(session_id.clone(), request_id.clone())); + } + request_ids + }; + for request_id in request_ids { + deliver_tool_permission(&agent, request_id.clone(), Permission::AllowOnce).await; + if let Some(item) = update_live_permission_status( + &state.live_timelines, + &session_id, + AgentPermissionRouting::Desktop, + &request_id, + "allow_once", + ) + .await + { + emit_agent_event( + &state.host.events, + AgentServiceEvent::TimelineItem { + session_id: session_id.clone(), + run_id: None, + item, + }, + ); + } + } + } + + // The policy is already committed at this point. A best-effort refresh + // must not report failure to the selector and make it roll back to a mode + // that is no longer authoritative. + match session_manager.get_session(&session_id, false).await { Ok(session) => emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "sessionUpdated".to_string(), - session_id: Some(session_id), + &state.host.events, + AgentServiceEvent::SessionUpdated { + session_id, run_id: None, - item: None, - status: None, - session: Some(session_summary(&session)), - message: None, + session: session_summary(&session), }, ), Err(error) => log::warn!( "Agent permission mode was updated, but the refreshed session could not be loaded: {error}" ), } - Ok(()) -} + Ok(()) + } -#[tauri::command] -pub async fn agent_permission_respond( - app_handle: AppHandle, - state: State<'_, AgentRuntimeState>, - user_id: String, - response: AgentPermissionResponse, -) -> Result<(), String> { - let account_scope = account_scope(&user_id)?; - let generation = account_generation(&state, &account_scope).await; - let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; - ensure_account_generation(&state, &account_scope, generation).await?; - let (agent_manager, session_id) = { - 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, &account_scope)?; - let session_id = response.session_id.trim().to_string(); + pub(crate) async fn permission_respond( + &self, + response: AgentPermissionResponse, + ) -> Result<(), String> { + let decision = permission_decision_from_str(&response.decision)?; + let display_status = response.decision.clone(); + self.resolve_permission( + response.session_id, + response.request_id, + decision, + AgentPermissionResponseScope::Desktop, + Some(display_status), + ) + .await + } + + async fn permission_respond_for_run( + &self, + session_id: &str, + run_id: &str, + request_id: String, + decision: AgentPermissionDecision, + ) -> Result<(), String> { + self.resolve_permission( + session_id.to_string(), + request_id, + decision, + AgentPermissionResponseScope::CallingSurface { + run_id: run_id.to_string(), + }, + None, + ) + .await + } + + async fn resolve_permission( + &self, + session_id: String, + request_id: String, + decision: AgentPermissionDecision, + scope: AgentPermissionResponseScope, + display_status: Option, + ) -> Result<(), String> { + let state = &self.service; + let account_scope = self.account_scope.as_ref(); + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation().await?; + self.ensure_accepting_new_work()?; + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let session_id = session_id.trim().to_string(); if session_id.is_empty() { return Err("Agent permission response requires a task ID".to_string()); } - let key = (session_id.clone(), response.request_id.clone()); - if !state.pending_permissions.lock().await.contains_key(&key) { - return Err(format!( - "No pending Agent Mode permission request found for {} in task {}", - response.request_id, session_id - )); + if request_id.trim().is_empty() { + return Err("Agent permission response requires a request ID".to_string()); } - (Arc::clone(¤t.agent_manager), session_id) - }; - let agent = agent_manager - .get_or_create_agent(session_id.clone()) - .await - .map_err(|e| format!("Failed to resolve Goose agent for permission response: {e}"))?; - agent - .handle_confirmation( - response.request_id.clone(), - PermissionConfirmation { - principal_type: PrincipalType::Tool, - permission: permission_from_decision(&response.decision)?, - }, + let (agent, run_id, expected_routing, run_events, cancelled_permission_ids) = { + 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, account_scope)?; + let (run_id, expected_routing, active_run) = match &scope { + AgentPermissionResponseScope::Desktop => { + let (run_id, active_run) = current + .active_runs + .iter() + .find(|(_, run)| run.session_id == session_id) + .ok_or_else(|| { + format!( + "No running Agent task found for permission request {request_id}" + ) + })?; + (run_id.clone(), AgentPermissionRouting::Desktop, active_run) + } + AgentPermissionResponseScope::CallingSurface { run_id } => { + let active_run = current.active_runs.get(run_id).ok_or_else(|| { + format!("No running Agent task found for permission request {request_id}") + })?; + if active_run.session_id != session_id { + return Err("Agent permission responder does not own this task".to_string()); + } + ( + run_id.clone(), + AgentPermissionRouting::CallingSurface, + active_run, + ) + } + }; + if active_run.token.is_cancelled() { + return Err("Agent permission request is already cancelled".to_string()); + } + ( + Arc::clone(&active_run.agent), + run_id, + expected_routing, + active_run.events.clone(), + Arc::clone(&active_run.cancelled_permission_ids), + ) + }; + let key = (session_id.clone(), request_id.clone()); + { + let mut pending = state.pending_permissions.lock().await; + let Some(request) = pending.get(&key) else { + return Err(format!( + "No pending Agent Mode permission request found for {request_id} in task {session_id}" + )); + }; + if request.run_id != run_id || request.routing != expected_routing { + return Err("Agent permission responder does not own this request".to_string()); + } + pending.remove(&key); + } + if decision == AgentPermissionDecision::Cancel { + cancelled_permission_ids + .lock() + .await + .insert(request_id.clone()); + } + agent + .handle_confirmation( + request_id.clone(), + PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: decision.goose_permission(), + }, + ) + .await; + if let Some(item) = update_live_permission_status( + &state.live_timelines, + &session_id, + expected_routing, + &request_id, + display_status + .as_deref() + .unwrap_or_else(|| decision.status()), ) - .await; - if let Some(item) = update_live_permission_status( - &state.live_timelines, - &session_id, - &response.request_id, - &response.decision, - ) - .await - { - emit_agent_event( - &app_handle, - AgentEventEnvelope { - event_type: "timelineItem".to_string(), - session_id: Some(session_id.clone()), - run_id: None, - item: Some(item), - status: None, - session: None, - message: None, - }, - ); - } - state - .pending_permissions - .lock() .await - .remove(&(session_id, response.request_id)); + { + match scope { + AgentPermissionResponseScope::Desktop => emit_agent_event( + &state.host.events, + AgentServiceEvent::TimelineItem { + session_id, + run_id: None, + item, + }, + ), + AgentPermissionResponseScope::CallingSurface { .. } => { + run_events.publish(AgentRunEvent::TimelineItem(item)).await; + } + } + } + Ok(()) + } +} + +fn validate_run_cancellation_scope( + actual_session_id: &str, + actual_routing: AgentPermissionRouting, + expected_session_id: Option<&str>, + expected_routing: AgentPermissionRouting, +) -> Result<(), String> { + if actual_routing != expected_routing { + return Err("Agent run is controlled by another Agent surface".to_string()); + } + if expected_session_id.is_some_and(|session_id| session_id != actual_session_id) { + return Err("Agent run cancellation capability does not own this task".to_string()); + } Ok(()) } struct AgentPromptRun { - app_handle: AppHandle, + events: AgentRunEventPublisher, agent: Arc, session_manager: Arc, live_timelines: LiveTimelines, session_id: String, - run_id: String, user_message: Message, permission_modes: SessionPermissionModes, web_tool_state: Arc, web_permission_context: WebPermissionContext, cancel_token: CancellationToken, pending_permissions: PendingPermissions, + issued_permission_ids: IssuedPermissionIds, cancelled_permission_ids: CancelledPermissionIds, + run_id: String, + permission_routing: AgentPermissionRouting, } #[derive(Default)] @@ -2561,29 +3467,63 @@ struct LiveMessageCandidate { } fn apply_successful_prompt_outcome( - timelines: &mut HashMap, + timelines: &mut HashMap, session_id: &str, + routing: AgentPermissionRouting, outcome: &AgentPromptOutcome, ) { + if routing == AgentPermissionRouting::CallingSurface { + remove_live_timeline_for_routing(timelines, session_id, routing); + return; + } match outcome.terminal_message.as_ref() { Some(candidate) => { timelines.insert( session_id.to_string(), - LiveTimeline::Completed(candidate.clone()), + LiveTimelineEntry { + routing, + timeline: LiveTimeline::Completed(candidate.clone()), + }, ); } None => { - timelines.remove(session_id); + remove_live_timeline_for_routing(timelines, session_id, routing); } } } fn apply_failed_prompt_outcome( - timelines: &mut HashMap, + timelines: &mut HashMap, session_id: &str, + routing: AgentPermissionRouting, item: AgentTimelineItem, ) { - timelines.insert(session_id.to_string(), LiveTimeline::Failed(vec![item])); + if routing == AgentPermissionRouting::CallingSurface { + remove_live_timeline_for_routing(timelines, session_id, routing); + return; + } + timelines.insert( + session_id.to_string(), + LiveTimelineEntry { + routing, + timeline: LiveTimeline::Failed(vec![item]), + }, + ); +} + +fn remove_live_timeline_for_routing( + timelines: &mut HashMap, + session_id: &str, + routing: AgentPermissionRouting, +) -> Option { + timelines + .get(session_id) + .is_some_and(|entry| entry.routing == routing) + .then(|| { + timelines + .remove(session_id) + .expect("matching live timeline must still exist") + }) } async fn selected_permission_mode( @@ -2861,21 +3801,77 @@ async fn automatically_handle_permissions( handled } +struct ExtractedToolPermissionRequests { + requests: HashMap, + conflicting_ids: HashSet, +} + +fn tool_permission_requests(message: &Message) -> ExtractedToolPermissionRequests { + let mut requests = HashMap::new(); + let mut conflicting_ids = HashSet::new(); + for content in &message.content { + let MessageContent::ActionRequired(action) = content else { + continue; + }; + let ActionRequiredData::ToolConfirmation { + id, + tool_name, + arguments, + prompt, + } = &action.data + else { + continue; + }; + if id.trim().is_empty() { + conflicting_ids.insert(id.clone()); + continue; + } + let request = AgentPermissionRequest { + request_id: id.clone(), + tool_name: tool_name.clone(), + arguments: arguments.clone(), + prompt: prompt.clone(), + }; + if conflicting_ids.contains(id) { + continue; + } + match requests.get(id) { + Some(_) => { + // A request ID is a one-shot capability. Even byte-for-byte + // duplicate entries in the same Goose message are ambiguous: + // registering one and suppressing the other can accidentally + // suppress the only caller-visible prompt. Fail closed instead. + requests.remove(id); + conflicting_ids.insert(id.clone()); + } + None => { + requests.insert(id.clone(), request); + } + } + } + ExtractedToolPermissionRequests { + requests, + conflicting_ids, + } +} + async fn run_agent_prompt(run: AgentPromptRun) -> Result { let AgentPromptRun { - app_handle, + events, agent, session_manager, live_timelines, session_id, - run_id, user_message, permission_modes, web_tool_state, web_permission_context, cancel_token, pending_permissions, + issued_permission_ids, cancelled_permission_ids, + run_id, + permission_routing, } = run; let mut terminal_message = None; let session_config = SessionConfig { @@ -2891,24 +3887,34 @@ async fn run_agent_prompt(run: AgentPromptRun) -> Result { + let extracted_permissions = tool_permission_requests(&message); + if !extracted_permissions.conflicting_ids.is_empty() { + for request_id in &extracted_permissions.conflicting_ids { + deliver_tool_permission(&agent, request_id.clone(), Permission::Cancel) + .await; + } + return Err(format!( + "Goose emitted an empty or conflicting permission request ID: {}", + extracted_permissions + .conflicting_ids + .iter() + .cloned() + .collect::>() + .join(", ") + )); + } + let permission_requests = extracted_permissions.requests; let automatically_handled = automatically_handle_permissions( &agent, &session_id, @@ -2934,53 +3940,69 @@ async fn run_agent_prompt(run: AgentPromptRun) -> Result { cancelled_permission_ids .lock() .await .insert(request_id.clone()); + deliver_tool_permission(&agent, request_id, Permission::Cancel) + .await; + item.status = Some("cancelled".to_string()); + } + PendingPermissionRegistration::Existing => { + duplicate_permissions.insert(request_id); + } + PendingPermissionRegistration::Registered => { + if claim_pending_permission_if_auto( + &agent, + &session_id, + &permission_modes, + &pending_permissions, + &request_id, + &cancel_token, + ) + .await + { + if cancel_token.is_cancelled() { + cancelled_permission_ids + .lock() + .await + .insert(request_id.clone()); + } + newly_auto_handled.insert(request_id); + } } - newly_auto_handled.insert(request_id); } } } items.retain(|item| { - pending_permission_request_id(item) - .is_none_or(|request_id| !newly_auto_handled.contains(&request_id)) + pending_permission_request_id(item).is_none_or(|request_id| { + !newly_auto_handled.contains(&request_id) + && !duplicate_permissions.contains(&request_id) + }) }); // Publish a permission card while holding the same claim lock // used by an Allow-all transition. If that transition already @@ -3010,11 +4032,29 @@ async fn run_agent_prompt(run: AgentPromptRun) -> Result Result { return Err(format!("Goose stream failed: {error}")); @@ -3180,8 +4210,8 @@ fn pending_permission_request_id(item: &AgentTimelineItem) -> Option { None } -fn project_skills_are_trusted(app_handle: &AppHandle, user_id: &str, project_root: &Path) -> bool { - match load_agent_config_inner(app_handle, user_id) { +fn project_skills_are_trusted(paths: &AgentPathLayout, user_id: &str, project_root: &Path) -> bool { + match load_agent_config_inner(paths, user_id) { Ok(config) => { project_skills_trust_status(&config, project_root, true).decision == Some(true) } @@ -3202,11 +4232,11 @@ fn project_skills_root_is_available(project_root: &Path) -> bool { } fn skills_discovery_working_dir( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, session: &Session, ) -> Result { - if project_skills_are_trusted(app_handle, user_id, &session.working_dir) { + if project_skills_are_trusted(paths, user_id, &session.working_dir) { if project_skills_root_is_available(&session.working_dir) { return Ok(session.working_dir.clone()); } @@ -3216,7 +4246,7 @@ fn skills_discovery_working_dir( ); } - let root = agent_config_dir(app_handle, user_id) + let root = agent_config_dir(paths, user_id) .map_err(|error| format!("Failed to locate Maple skills data: {error}"))? .join("untrusted-project-skills"); fs::create_dir_all(&root) @@ -3261,12 +4291,12 @@ fn skills_client_for_working_dir( } fn prepare_transient_skills_client( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, agent: &Arc, session: &Session, ) -> Result { - let working_dir = skills_discovery_working_dir(app_handle, user_id, session)?; + let working_dir = skills_discovery_working_dir(paths, user_id, session)?; skills_client_for_working_dir(agent, session, working_dir) } @@ -3284,7 +4314,7 @@ async fn attach_prepared_skills_client(agent: &Arc, skills_client: Skills } struct AgentSkillsScope<'a> { - app_handle: &'a AppHandle, + paths: &'a AgentPathLayout, user_id: &'a str, } @@ -3295,6 +4325,7 @@ struct SessionAgentConfiguration<'a> { context_limit: Option, mode: &'a str, primary_model_supports_vision: bool, + tool_context: &'a SharedAgentToolContext, } fn maple_model_config( @@ -3401,6 +4432,7 @@ async fn configure_session_agent( context_limit, mode, primary_model_supports_vision, + tool_context, } = configuration; let session_mcp_keys = session_mcp_extension_keys(session); let manager_result = get_or_create_session_agent( @@ -3411,12 +4443,8 @@ async fn configure_session_agent( ) .await?; let agent = manager_result.agent; - let skills_client = prepare_transient_skills_client( - skills_scope.app_handle, - skills_scope.user_id, - &agent, - session, - )?; + let skills_client = + prepare_transient_skills_client(skills_scope.paths, skills_scope.user_id, &agent, session)?; let mcp_errors = mcp_connection_errors(manager_result.extension_results, &session_mcp_keys); install_maple_provider(&agent, maple_api_session, session, model, context_limit).await?; agent @@ -3444,6 +4472,7 @@ async fn configure_session_agent( primary_model_supports_vision, web_transport, Arc::clone(web_tool_state), + tool_context.clone(), ) .map_err(|e| format!("Failed to create Maple developer tools: {e}"))?; agent @@ -3520,6 +4549,11 @@ fn conversation_to_timeline_items(conversation: &Conversation) -> Vec {} } } + settle_turn_permission_items( + &mut items[current_turn_item_start..], + &resolved_permission_ids, + false, + ); let visible_message = message.user_visible_content(); // Match Goose's own session presentation contract: agent-only grind, @@ -3565,23 +4599,11 @@ fn conversation_to_timeline_items(conversation: &Conversation) -> Vec bool { }) } +fn settle_turn_permission_items( + items: &mut [AgentTimelineItem], + resolved_ids: &HashSet, + cancel_unresolved: bool, +) { + for item in items { + if item.item_type != "permission" || item.status.as_deref() != Some("pending") { + continue; + } + let resolved = item + .id + .strip_prefix("permission-") + .or_else(|| item.id.strip_prefix("elicitation-")) + .is_some_and(|id| resolved_ids.contains(id)); + if resolved { + item.status = Some("completed".to_string()); + } else if cancel_unresolved { + item.status = Some("cancelled".to_string()); + } + } +} + +fn reconcile_desktop_permission_items( + items: &mut [AgentTimelineItem], + pending_routes: &HashMap, + calling_surface_active: bool, +) { + for item in items { + if item.item_type != "permission" || item.status.as_deref() != Some("pending") { + continue; + } + let request_id = item + .id + .strip_prefix("permission-") + .or_else(|| item.id.strip_prefix("elicitation-")); + let route = request_id.and_then(|id| pending_routes.get(id)); + item.status = match (calling_surface_active, route) { + (false, Some(AgentPermissionRouting::Desktop)) => continue, + (true, _) | (_, Some(AgentPermissionRouting::CallingSurface)) => { + Some("controlled_externally".to_string()) + } + (false, None) => Some("cancelled".to_string()), + }; + } +} + fn is_real_user_message(message: &Message, role: &str) -> bool { if role != "user" || !message.is_user_visible() { return false; @@ -4196,11 +5264,11 @@ fn tool_response_title(id: &str) -> Option { }) } -fn permission_from_decision(decision: &str) -> Result { +fn permission_decision_from_str(decision: &str) -> Result { match decision { - "allow_once" | "allow" => Ok(Permission::AllowOnce), - "deny_once" | "deny" => Ok(Permission::DenyOnce), - "cancel" => Ok(Permission::Cancel), + "allow_once" | "allow" => Ok(AgentPermissionDecision::AllowOnce), + "deny_once" | "deny" => Ok(AgentPermissionDecision::DenyOnce), + "cancel" | "cancelled" => Ok(AgentPermissionDecision::Cancel), "always_allow" | "always_deny" => { Err("Persistent tool permissions are not supported by Maple Agent Mode".to_string()) } @@ -4208,6 +5276,11 @@ fn permission_from_decision(decision: &str) -> Result { } } +#[cfg(test)] +fn permission_from_decision(decision: &str) -> Result { + permission_decision_from_str(decision).map(AgentPermissionDecision::goose_permission) +} + fn session_summary(session: &Session) -> AgentSessionSummary { AgentSessionSummary { id: session.id.clone(), @@ -4228,60 +5301,55 @@ fn sort_sessions_newest_first(sessions: &mut [AgentSessionSummary]) { sessions.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); } -fn emit_timeline_item( - app_handle: &AppHandle, - session_id: &str, - run_id: &str, - item: AgentTimelineItem, -) { - emit_agent_event( - app_handle, - AgentEventEnvelope { - event_type: "timelineItem".to_string(), - session_id: Some(session_id.to_string()), - run_id: Some(run_id.to_string()), - item: Some(item), - status: None, - session: None, - message: None, - }, - ); -} - async fn record_and_emit_timeline_item( - app_handle: &AppHandle, + events: &AgentRunEventPublisher, live_timelines: &LiveTimelines, session_id: &str, - run_id: &str, + routing: AgentPermissionRouting, item: AgentTimelineItem, ) { - record_timeline_item(live_timelines, session_id, item.clone()).await; - emit_timeline_item(app_handle, session_id, run_id, item); + record_timeline_item(live_timelines, session_id, routing, item.clone()).await; + events.publish(AgentRunEvent::TimelineItem(item)).await; } async fn record_timeline_item( live_timelines: &LiveTimelines, session_id: &str, + routing: AgentPermissionRouting, item: AgentTimelineItem, ) { let mut timelines = live_timelines.lock().await; let current = match timelines.remove(session_id) { - Some(LiveTimeline::Streaming(items)) => items, + Some(LiveTimelineEntry { + routing: owner, + timeline: LiveTimeline::Streaming(items), + }) if owner == routing => items, // A real user message starts a new live suffix. The preceding terminal // row is either already persisted or was a one-turn-only error/notice; // carrying it forward could duplicate it on a mid-run session reload. - Some(LiveTimeline::Completed(_) | LiveTimeline::Failed(_)) - if is_user_message_item(&item) => - { - Vec::new() - } - Some(LiveTimeline::Completed(candidate)) => candidate.items, - Some(LiveTimeline::Failed(items)) => items, + Some(LiveTimelineEntry { + routing: owner, + timeline: LiveTimeline::Completed(_) | LiveTimeline::Failed(_), + }) if owner == routing && is_user_message_item(&item) => Vec::new(), + Some(LiveTimelineEntry { + routing: owner, + timeline: LiveTimeline::Completed(candidate), + }) if owner == routing => candidate.items, + Some(LiveTimelineEntry { + routing: owner, + timeline: LiveTimeline::Failed(items), + }) if owner == routing => items, + // A new surface starts its own transient projection. Persisted Goose + // history remains the shared handoff boundary between surfaces. + Some(_) => Vec::new(), None => Vec::new(), }; timelines.insert( session_id.to_string(), - LiveTimeline::Streaming(merge_timeline_item(current, item)), + LiveTimelineEntry { + routing, + timeline: LiveTimeline::Streaming(merge_timeline_item(current, item)), + }, ); } @@ -4293,6 +5361,7 @@ async fn record_timeline_item( async fn reseed_live_timeline_after_history_replaced( live_timelines: &LiveTimelines, session_id: &str, + routing: AgentPermissionRouting, conversation: &Conversation, ) { let replacement_boundary = conversation @@ -4318,8 +5387,9 @@ async fn reseed_live_timeline_after_history_replaced( // that compaction or an explicit history command removed. let boundary = timelines .get(session_id) - .and_then(|items| { - items.items().iter().rev().find(|item| { + .filter(|entry| entry.routing == routing) + .and_then(|entry| { + entry.timeline.items().iter().rev().find(|item| { is_user_message_item(item) && item.id == replacement_boundary.id }) }) @@ -4327,11 +5397,14 @@ async fn reseed_live_timeline_after_history_replaced( .unwrap_or(replacement_boundary); timelines.insert( session_id.to_string(), - LiveTimeline::Streaming(vec![boundary]), + LiveTimelineEntry { + routing, + timeline: LiveTimeline::Streaming(vec![boundary]), + }, ); } None => { - timelines.remove(session_id); + remove_live_timeline_for_routing(&mut timelines, session_id, routing); } } } @@ -4339,19 +5412,24 @@ async fn reseed_live_timeline_after_history_replaced( async fn overlay_live_timeline( live_timelines: &LiveTimelines, session_id: &str, + routing: AgentPermissionRouting, conversation: &Conversation, persisted: Vec, ) -> Vec { let live_items = { let mut timelines = live_timelines.lock().await; - match timelines.get(session_id).cloned() { + let timeline = timelines + .get(session_id) + .filter(|entry| entry.routing == routing) + .map(|entry| entry.timeline.clone()); + match timeline { Some(LiveTimeline::Streaming(items)) => items, Some(LiveTimeline::Completed(candidate)) => { // agent_load_session already paid to load Goose history. Use // that snapshot here instead of deserializing it a second time // at the end of every prompt. if terminal_message_is_persisted(conversation, &candidate) { - timelines.remove(session_id); + remove_live_timeline_for_routing(&mut timelines, session_id, routing); Vec::new() } else { candidate.items @@ -4401,22 +5479,25 @@ fn live_overlay_item(mut item: AgentTimelineItem) -> AgentTimelineItem { async fn update_live_permission_status( live_timelines: &LiveTimelines, session_id: &str, + routing: AgentPermissionRouting, request_id: &str, decision: &str, ) -> Option { let permission_id = format!("permission-{request_id}"); let mut timelines = live_timelines.lock().await; - let items = timelines.get_mut(session_id)?.items_mut(); + let entry = timelines.get_mut(session_id)?; + if entry.routing != routing { + return None; + } + let items = entry.timeline.items_mut(); let item = items.iter_mut().find(|item| item.id == permission_id)?; item.status = Some(decision.to_string()); item.merge = "replace".to_string(); Some(item.clone()) } -fn emit_agent_event(app_handle: &AppHandle, event: AgentEventEnvelope) { - if let Err(error) = app_handle.emit(AGENT_EVENT_NAME, event) { - log::warn!("Failed to emit Agent Mode event: {error}"); - } +fn emit_agent_event(events: &AgentEventDispatcher, event: AgentServiceEvent) { + events.sink.emit(&event); } fn configure_embedded_goose(goose_path_root: &Path, model: &str, mode: &str) -> Result<(), String> { @@ -4894,49 +5975,41 @@ fn normalize_project_root(path: &Path) -> Result { Ok(canonical) } -fn agent_root_dir(app_handle: &AppHandle) -> Result { - let base = app_handle - .path() - .app_config_dir() - .map_err(|error| anyhow::anyhow!("Failed to resolve app config dir: {error}"))?; - let path = base.join("agent"); +fn agent_root_dir(paths: &AgentPathLayout) -> Result { + let path = paths.config_root.clone(); fs::create_dir_all(&path)?; set_owner_only_dir_permissions(&path); Ok(path) } fn account_config_dir_path( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, ) -> Result { let scope = account_scope(user_id).map_err(anyhow::Error::msg)?; - Ok(agent_root_dir(app_handle)?.join("accounts").join(scope)) + Ok(agent_root_dir(paths)?.join("accounts").join(scope)) } fn account_local_data_dir_path( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, ) -> Result { let scope = account_scope(user_id).map_err(anyhow::Error::msg)?; - let base = app_handle - .path() - .app_local_data_dir() - .map_err(|error| anyhow::anyhow!("Failed to resolve app local data dir: {error}"))?; - Ok(base.join("agent").join("accounts").join(scope)) + Ok(paths.local_data_root.join("accounts").join(scope)) } -fn agent_config_dir(app_handle: &AppHandle, user_id: &str) -> Result { - let path = account_config_dir_path(app_handle, user_id)?; +fn agent_config_dir(paths: &AgentPathLayout, user_id: &str) -> Result { + let path = account_config_dir_path(paths, user_id)?; fs::create_dir_all(&path)?; set_owner_only_dir_permissions(&path); Ok(path) } fn account_session_manager( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, ) -> Result, String> { - let account_dir = agent_config_dir(app_handle, user_id).map_err(|error| error.to_string())?; + let account_dir = agent_config_dir(paths, user_id).map_err(|error| error.to_string())?; session_manager_for_account_dir(&account_dir) } @@ -4965,12 +6038,12 @@ fn remove_agent_history_path(path: &Path) -> Result<(), anyhow::Error> { result.map_err(Into::into) } fn load_agent_config_inner( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, ) -> Result { - let path = agent_config_dir(app_handle, user_id)?.join("config.json"); + let path = agent_config_dir(paths, user_id)?.join("config.json"); let removed_project_roots_path = - account_local_data_dir_path(app_handle, user_id)?.join("removed_project_roots.json"); + account_local_data_dir_path(paths, user_id)?.join("removed_project_roots.json"); load_agent_config_files(&path, &removed_project_roots_path) } @@ -5012,11 +6085,11 @@ fn migrate_agent_config(config: &mut AgentConfig) -> bool { } fn save_agent_config_inner( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, config: &AgentConfig, ) -> Result<(), anyhow::Error> { - let path = agent_config_dir(app_handle, user_id)?.join("config.json"); + let path = agent_config_dir(paths, user_id)?.join("config.json"); save_agent_config_file(&path, config) } @@ -5040,11 +6113,11 @@ fn load_removed_project_roots_file(path: &Path) -> Result, anyhow::E } fn save_removed_project_roots_inner( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, roots: &[String], ) -> Result<(), anyhow::Error> { - let path = account_local_data_dir_path(app_handle, user_id)?.join("removed_project_roots.json"); + let path = account_local_data_dir_path(paths, user_id)?.join("removed_project_roots.json"); write_device_local_json_file(&path, roots) } @@ -5090,10 +6163,10 @@ fn apply_project_skills_trust( } fn load_recent_project_roots_inner( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, ) -> Result, anyhow::Error> { - let path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + let path = agent_config_dir(paths, user_id)?.join("recent_roots.json"); load_recent_project_roots_file(&path) } @@ -5178,11 +6251,11 @@ fn register_explicit_project_root_file( } fn register_explicit_project_root_inner( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, project_root: &Path, ) -> Result, anyhow::Error> { - let file_path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + let file_path = agent_config_dir(paths, user_id)?.join("recent_roots.json"); register_explicit_project_root_file(&file_path, project_root, unix_ms()) } @@ -5215,11 +6288,11 @@ fn restore_explicit_project_root_file( } fn restore_explicit_project_root_inner( - app_handle: &AppHandle, + paths: &AgentPathLayout, user_id: &str, project_root: &Path, ) -> Result, anyhow::Error> { - let file_path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + let file_path = agent_config_dir(paths, user_id)?.join("recent_roots.json"); restore_explicit_project_root_file(&file_path, project_root, unix_ms()) } @@ -5275,12 +6348,12 @@ fn save_project_root_order_file( } fn save_project_root_order_inner( - app_handle: &AppHandle, + layout: &AgentPathLayout, user_id: &str, mut paths: Vec, ) -> Result, anyhow::Error> { - let file_path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); - let removed = load_agent_config_inner(app_handle, user_id)? + let file_path = agent_config_dir(layout, user_id)?.join("recent_roots.json"); + let removed = load_agent_config_inner(layout, user_id)? .removed_project_roots .into_iter() .collect::>(); @@ -5424,6 +6497,27 @@ fn path_string(path: &Path) -> String { mod tests { use super::*; use rmcp::model::{AnnotateAble, RawTextContent, Role as McpRole}; + use std::collections::{BTreeMap, BTreeSet}; + + struct NoopAgentEventSink; + + impl AgentEventSink for NoopAgentEventSink { + fn emit(&self, _event: &AgentServiceEvent) {} + } + + #[derive(Default)] + struct RecordingAgentEventSink { + events: std::sync::Mutex>, + } + + impl AgentEventSink for RecordingAgentEventSink { + fn emit(&self, event: &AgentServiceEvent) { + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(event.clone()); + } + } struct InertMapleTransport; @@ -5463,6 +6557,83 @@ mod tests { roots.iter().map(|root| root.path.clone()).collect() } + fn test_permission_request(request_id: &str) -> AgentPermissionRequest { + AgentPermissionRequest { + request_id: request_id.to_string(), + tool_name: "shell".to_string(), + arguments: serde_json::Map::from_iter([( + "command".to_string(), + Value::String("git status --short".to_string()), + )]), + prompt: Some("Run this command?".to_string()), + } + } + + fn test_pending_permission( + run_id: &str, + routing: AgentPermissionRouting, + request_id: &str, + ) -> PendingAgentPermission { + PendingAgentPermission { + run_id: run_id.to_string(), + routing, + request: test_permission_request(request_id), + } + } + + fn test_live_timeline( + routing: AgentPermissionRouting, + timeline: LiveTimeline, + ) -> LiveTimelineEntry { + LiveTimelineEntry { routing, timeline } + } + + #[test] + fn desktop_status_excludes_calling_surface_runs() { + let status = active_run_status([ + ( + "desktop-run", + "desktop-session", + AgentPermissionRouting::Desktop, + ), + ( + "acp-run", + "acp-session", + AgentPermissionRouting::CallingSurface, + ), + ]); + + assert_eq!( + status, + HashMap::from([("desktop-session".to_string(), "desktop-run".to_string())]) + ); + } + + #[test] + fn run_cancellation_scope_rejects_cross_surface_and_wrong_session_access() { + assert!(validate_run_cancellation_scope( + "session-1", + AgentPermissionRouting::CallingSurface, + None, + AgentPermissionRouting::Desktop, + ) + .is_err()); + assert!(validate_run_cancellation_scope( + "session-1", + AgentPermissionRouting::CallingSurface, + Some("session-2"), + AgentPermissionRouting::CallingSurface, + ) + .is_err()); + assert!(validate_run_cancellation_scope( + "session-1", + AgentPermissionRouting::CallingSurface, + Some("session-1"), + AgentPermissionRouting::CallingSurface, + ) + .is_ok()); + } + #[test] fn fresh_agent_config_defaults_to_glm() { assert_eq!(AgentConfig::default().default_model, DEFAULT_AGENT_MODEL); @@ -7219,12 +8390,16 @@ mod tests { let reply_candidate = live_message_candidate(&live_reply, &reply_items); let live_timelines = Arc::new(Mutex::new(HashMap::from([( session_id.to_string(), - LiveTimeline::Completed(reply_candidate), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Completed(reply_candidate), + ), )]))); let loaded = overlay_live_timeline( &live_timelines, session_id, + AgentPermissionRouting::Desktop, &persisted_conversation, persisted_timeline.clone(), ) @@ -7241,6 +8416,7 @@ mod tests { apply_successful_prompt_outcome( &mut timelines, session_id, + AgentPermissionRouting::Desktop, &AgentPromptOutcome { terminal_message: Some(notice_candidate), }, @@ -7249,6 +8425,7 @@ mod tests { let loaded = overlay_live_timeline( &live_timelines, session_id, + AgentPermissionRouting::Desktop, &persisted_conversation, persisted_timeline, ) @@ -7259,11 +8436,167 @@ mod tests { ); assert!(matches!( live_timelines.lock().await.get(session_id), - Some(LiveTimeline::Completed(_)) + Some(LiveTimelineEntry { + routing: AgentPermissionRouting::Desktop, + timeline: LiveTimeline::Completed(_), + }) )); let mut timelines = live_timelines.lock().await; - apply_successful_prompt_outcome(&mut timelines, session_id, &AgentPromptOutcome::default()); + apply_successful_prompt_outcome( + &mut timelines, + session_id, + AgentPermissionRouting::Desktop, + &AgentPromptOutcome::default(), + ); + assert!(!timelines.contains_key(session_id)); + } + + #[tokio::test] + async fn calling_surface_timeline_does_not_overlay_desktop_session_load() { + let session_id = "calling-surface-session"; + let persisted_conversation = Conversation::new_unvalidated(vec![ + Message::user() + .with_id("persisted-user") + .with_text("Persisted prompt"), + Message::assistant() + .with_content(MessageContent::action_required( + "persisted-request", + "shell".to_string(), + serde_json::Map::new(), + Some("Run this command?".to_string()), + )) + .with_generated_id(), + ]); + let persisted = conversation_to_timeline_items(&persisted_conversation); + let permission = AgentTimelineItem { + id: "permission-request-1".to_string(), + item_type: "permission".to_string(), + role: Some("assistant".to_string()), + title: Some("shell".to_string()), + text: Some("Run this command?".to_string()), + status: Some("pending".to_string()), + input: Some(json!({ "command": "git status --short" })), + output: None, + created_ms: 1, + merge: "replace".to_string(), + }; + let live_timelines = Arc::new(Mutex::new(HashMap::from([( + session_id.to_string(), + test_live_timeline( + AgentPermissionRouting::CallingSurface, + LiveTimeline::Streaming(vec![permission]), + ), + )]))); + + let mut loaded = overlay_live_timeline( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + &persisted_conversation, + persisted.clone(), + ) + .await; + reconcile_desktop_permission_items(&mut loaded, &HashMap::new(), true); + + assert_eq!( + loaded + .iter() + .find(|item| item.id == "permission-persisted-request") + .and_then(|item| item.status.as_deref()), + Some("controlled_externally") + ); + assert!(!loaded.iter().any(|item| { + item.item_type == "permission" && item.status.as_deref() == Some("pending") + })); + assert_eq!( + live_timelines.lock().await.get(session_id).unwrap().routing, + AgentPermissionRouting::CallingSurface + ); + } + + #[tokio::test] + async fn permission_status_updates_only_the_owning_surface_timeline() { + let session_id = "permission-owner-session"; + let permission = AgentTimelineItem { + id: "permission-request-1".to_string(), + item_type: "permission".to_string(), + role: Some("assistant".to_string()), + title: Some("shell".to_string()), + text: None, + status: Some("pending".to_string()), + input: None, + output: None, + created_ms: 1, + merge: "replace".to_string(), + }; + let live_timelines = Arc::new(Mutex::new(HashMap::from([( + session_id.to_string(), + test_live_timeline( + AgentPermissionRouting::CallingSurface, + LiveTimeline::Streaming(vec![permission]), + ), + )]))); + + assert!(update_live_permission_status( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + "request-1", + "allow_once", + ) + .await + .is_none()); + assert_eq!( + update_live_permission_status( + &live_timelines, + session_id, + AgentPermissionRouting::CallingSurface, + "request-1", + "allow_once", + ) + .await + .and_then(|item| item.status), + Some("allow_once".to_string()) + ); + } + + #[test] + fn calling_surface_terminal_cleanup_cannot_remove_desktop_live_state() { + let session_id = "terminal-owner-session"; + let desktop_item = error_item("Desktop-only state".to_string()); + let mut timelines = HashMap::from([( + session_id.to_string(), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(vec![desktop_item]), + ), + )]); + + apply_successful_prompt_outcome( + &mut timelines, + session_id, + AgentPermissionRouting::CallingSurface, + &AgentPromptOutcome::default(), + ); + assert_eq!( + timelines.get(session_id).unwrap().routing, + AgentPermissionRouting::Desktop + ); + + timelines.insert( + session_id.to_string(), + test_live_timeline( + AgentPermissionRouting::CallingSurface, + LiveTimeline::Streaming(Vec::new()), + ), + ); + apply_successful_prompt_outcome( + &mut timelines, + session_id, + AgentPermissionRouting::CallingSurface, + &AgentPromptOutcome::default(), + ); assert!(!timelines.contains_key(session_id)); } @@ -7276,21 +8609,28 @@ mod tests { .with_text("Prior turn"), false, ); - let mut timelines = - HashMap::from([(session_id.to_string(), LiveTimeline::Streaming(prior_turn))]); + let mut timelines = HashMap::from([( + session_id.to_string(), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(prior_turn), + ), + )]); apply_failed_prompt_outcome( &mut timelines, session_id, + AgentPermissionRouting::Desktop, error_item("First failure".to_string()), ); apply_failed_prompt_outcome( &mut timelines, session_id, + AgentPermissionRouting::Desktop, error_item("Second failure".to_string()), ); - let LiveTimeline::Failed(items) = timelines.get(session_id).unwrap() else { + let LiveTimeline::Failed(items) = &timelines.get(session_id).unwrap().timeline else { panic!("failed run should leave a bounded failed timeline"); }; assert_eq!(items.len(), 1); @@ -7304,9 +8644,15 @@ mod tests { .into_iter() .next() .unwrap(); - record_timeline_item(&live_timelines, session_id, next_user).await; + record_timeline_item( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + next_user, + ) + .await; let timelines = live_timelines.lock().await; - let LiveTimeline::Streaming(items) = timelines.get(session_id).unwrap() else { + let LiveTimeline::Streaming(items) = &timelines.get(session_id).unwrap().timeline else { panic!("a retry should start a fresh streaming timeline"); }; assert_eq!(items.len(), 1); @@ -7486,33 +8832,130 @@ mod tests { .with_generated_id(); let unresolved_elicitation = Message::assistant() .with_content(MessageContent::action_required_elicitation( - "pending-input", + "pending-input", + "Need more input".to_string(), + json!({"type": "object"}), + )) + .with_generated_id(); + let stopped_notice = Message::assistant() + .with_system_notification(SystemNotificationType::InlineMessage, "Stopped by user") + .with_visibility(true, false) + .with_generated_id(); + let conversation = Conversation::new_unvalidated(vec![ + Message::user().with_text("run tools").with_generated_id(), + resolved_permission, + tool_response_message("resolved-response", "resolved-tool"), + unresolved_elicitation, + stopped_notice, + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert!(items.iter().any(|item| { + item.id == "permission-resolved-tool" && item.status.as_deref() == Some("completed") + })); + assert!(items.iter().any(|item| { + item.id == "elicitation-pending-input" && item.status.as_deref() == Some("cancelled") + })); + } + + #[test] + fn persisted_tool_permission_settles_without_stop_notice() { + let permission = Message::assistant() + .with_content(MessageContent::action_required( + "resolved-tool", + "shell".to_string(), + serde_json::Map::new(), + None, + )) + .with_generated_id(); + let conversation = Conversation::new_unvalidated(vec![ + Message::user().with_text("run tool").with_generated_id(), + permission, + tool_response_message("resolved-response", "resolved-tool"), + ]); + + let items = conversation_to_timeline_items(&conversation); + + assert!(items.iter().any(|item| { + item.id == "permission-resolved-tool" && item.status.as_deref() == Some("completed") + })); + } + + #[test] + fn persisted_elicitation_settles_from_agent_only_response() { + let request = Message::assistant() + .with_content(MessageContent::action_required_elicitation( + "resolved-input", "Need more input".to_string(), json!({"type": "object"}), )) .with_generated_id(); - let stopped_notice = Message::assistant() - .with_system_notification(SystemNotificationType::InlineMessage, "Stopped by user") - .with_visibility(true, false) + let response = Message::user() + .with_content(MessageContent::action_required_elicitation_response( + "resolved-input", + json!({"answer": "yes"}), + rmcp::model::ElicitationAction::Accept, + )) + .agent_only() .with_generated_id(); let conversation = Conversation::new_unvalidated(vec![ - Message::user().with_text("run tools").with_generated_id(), - resolved_permission, - tool_response_message("resolved-response", "resolved-tool"), - unresolved_elicitation, - stopped_notice, + Message::user().with_text("ask me").with_generated_id(), + request, + response, ]); let items = conversation_to_timeline_items(&conversation); assert!(items.iter().any(|item| { - item.id == "permission-resolved-tool" && item.status.as_deref() == Some("completed") - })); - assert!(items.iter().any(|item| { - item.id == "elicitation-pending-input" && item.status.as_deref() == Some("cancelled") + item.id == "elicitation-resolved-input" && item.status.as_deref() == Some("completed") })); } + #[test] + fn desktop_permission_reconciliation_preserves_only_desktop_owned_pending_rows() { + fn permission(id: &str, status: &str) -> AgentTimelineItem { + AgentTimelineItem { + id: format!("permission-{id}"), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some("Permission".to_string()), + text: None, + status: Some(status.to_string()), + input: None, + output: None, + created_ms: 1, + merge: "replace".to_string(), + } + } + + let original_completed = permission("completed", "completed"); + let mut items = vec![ + permission("desktop", "pending"), + permission("caller", "pending"), + permission("orphan", "pending"), + original_completed.clone(), + ]; + let routes = HashMap::from([ + ("desktop".to_string(), AgentPermissionRouting::Desktop), + ("caller".to_string(), AgentPermissionRouting::CallingSurface), + ]); + + reconcile_desktop_permission_items(&mut items, &routes, false); + + assert_eq!(items[0].status.as_deref(), Some("pending")); + assert_eq!(items[1].status.as_deref(), Some("controlled_externally")); + assert_eq!(items[2].status.as_deref(), Some("cancelled")); + assert_eq!(items[3], original_completed); + + let mut registration_race = vec![permission("not-registered-yet", "pending")]; + reconcile_desktop_permission_items(&mut registration_race, &HashMap::new(), true); + assert_eq!( + registration_race[0].status.as_deref(), + Some("controlled_externally") + ); + } + #[test] fn hides_tool_reasoning_after_prior_visible_thinking() { let surfaced = "Inspect the project before running both commands."; @@ -7991,16 +9434,25 @@ mod tests { )); let live_timelines = Arc::new(Mutex::new(HashMap::from([( session_id.to_string(), - LiveTimeline::Streaming(live), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(live), + ), )]))); - reseed_live_timeline_after_history_replaced(&live_timelines, session_id, &conversation) - .await; + reseed_live_timeline_after_history_replaced( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + &conversation, + ) + .await; let timelines = live_timelines.lock().await; let items = timelines .get(session_id) .expect("replacement should retain a user boundary") + .timeline .items(); assert_eq!(items.len(), 1); assert_eq!(items[0].id, "current-user-text"); @@ -8030,16 +9482,25 @@ mod tests { Conversation::new_unvalidated(vec![current_user.clone(), provider_only_user]); let live_timelines = Arc::new(Mutex::new(HashMap::from([( session_id.to_string(), - LiveTimeline::Streaming(message_to_timeline_items(¤t_user, false)), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(message_to_timeline_items(¤t_user, false)), + ), )]))); - reseed_live_timeline_after_history_replaced(&live_timelines, session_id, &conversation) - .await; + reseed_live_timeline_after_history_replaced( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + &conversation, + ) + .await; let timelines = live_timelines.lock().await; let items = timelines .get(session_id) .expect("the latest visible user boundary should survive") + .timeline .items(); assert_eq!(items.len(), 1); assert_eq!(items[0].id, "current-user-text"); @@ -8068,8 +9529,13 @@ mod tests { current_user.clone(), ]); let live_timelines = Arc::new(Mutex::new(HashMap::new())); - reseed_live_timeline_after_history_replaced(&live_timelines, session_id, &replacement) - .await; + reseed_live_timeline_after_history_replaced( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + &replacement, + ) + .await; let live_response = assistant_tool_message( "live-provider-response", @@ -8078,7 +9544,13 @@ mod tests { "", ); for item in message_to_timeline_items(&live_response, true) { - record_timeline_item(&live_timelines, session_id, item).await; + record_timeline_item( + &live_timelines, + session_id, + AgentPermissionRouting::Desktop, + item, + ) + .await; } let persisted_conversation = Conversation::new_unvalidated(vec![ @@ -8102,6 +9574,7 @@ mod tests { let overlaid = overlay_live_timeline( &live_timelines, session_id, + AgentPermissionRouting::Desktop, &persisted_conversation, persisted, ) @@ -8288,12 +9761,38 @@ mod tests { .expect("surviving session should be created"); let live_timelines = Arc::new(Mutex::new(HashMap::from([ - (target.id.clone(), LiveTimeline::Streaming(Vec::new())), - (survivor.id.clone(), LiveTimeline::Streaming(Vec::new())), + ( + target.id.clone(), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(Vec::new()), + ), + ), + ( + survivor.id.clone(), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(Vec::new()), + ), + ), ]))); let pending_permissions = Arc::new(Mutex::new(HashMap::from([ - ((target.id.clone(), "target-request".to_string()), ()), - ((survivor.id.clone(), "survivor-request".to_string()), ()), + ( + (target.id.clone(), "target-request".to_string()), + test_pending_permission( + "target-run", + AgentPermissionRouting::Desktop, + "target-request", + ), + ), + ( + (survivor.id.clone(), "survivor-request".to_string()), + test_pending_permission( + "survivor-run", + AgentPermissionRouting::Desktop, + "survivor-request", + ), + ), ]))); let web_tool_state = WebToolState::default(); let provenance_cancel = CancellationToken::new(); @@ -8439,13 +9938,17 @@ mod tests { let live_timelines = Arc::new(Mutex::new(HashMap::from([( session.id.clone(), - LiveTimeline::Streaming(vec![error_item("speculative partial event".to_string())]), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(vec![error_item("speculative partial event".to_string())]), + ), )]))); finalize_cancelled_agent_turn( &session_manager, &live_timelines, &web_tool_state, &session.id, + AgentPermissionRouting::Desktop, &stopped_user, &HashSet::from(["declined-tool".to_string()]), ) @@ -8531,13 +10034,17 @@ mod tests { .with_generated_id(); live_timelines.lock().await.insert( first_turn_session.id.clone(), - LiveTimeline::Streaming(vec![error_item("optimistic first turn".to_string())]), + test_live_timeline( + AgentPermissionRouting::Desktop, + LiveTimeline::Streaming(vec![error_item("optimistic first turn".to_string())]), + ), ); finalize_cancelled_agent_turn( &session_manager, &live_timelines, &web_tool_state, &first_turn_session.id, + AgentPermissionRouting::Desktop, &first_turn_user, &HashSet::new(), ) @@ -8566,21 +10073,233 @@ mod tests { } #[tokio::test] - async fn detects_active_run_for_session() { - let mut active_runs = HashMap::new(); - let task_handle = tauri::async_runtime::spawn(async {}); - active_runs.insert( + async fn run_event_streams_are_ordered_isolated_and_host_policy_controls_projection() { + let sink = Arc::new(RecordingAgentEventSink::default()); + let dispatcher = AgentEventDispatcher::new(sink.clone()); + let (first, mut first_events) = AgentRunEventPublisher::new( + dispatcher.clone(), + "session-1".to_string(), + "run-1".to_string(), + AgentHostEventPolicy::Publish, + ); + let (second, mut second_events) = AgentRunEventPublisher::new( + dispatcher.clone(), + "session-2".to_string(), + "run-2".to_string(), + AgentHostEventPolicy::Publish, + ); + let (external, mut external_events) = AgentRunEventPublisher::new( + dispatcher, + "session-3".to_string(), + "run-3".to_string(), + AgentHostEventPolicy::Suppress, + ); + + first.publish(AgentRunEvent::Started).await; + second + .publish(AgentRunEvent::SetupWarning("setup warning".to_string())) + .await; + first + .publish(AgentRunEvent::Finished(AgentRunTerminal::Completed)) + .await; + second + .publish(AgentRunEvent::Finished(AgentRunTerminal::Failed)) + .await; + external.publish(AgentRunEvent::Started).await; + + assert!(matches!( + first_events.recv().await, + Some(AgentRunEvent::Started) + )); + assert!(matches!( + first_events.recv().await, + Some(AgentRunEvent::Finished(AgentRunTerminal::Completed)) + )); + assert!(first_events.try_recv().is_err()); + + assert!(matches!( + second_events.recv().await, + Some(AgentRunEvent::SetupWarning(message)) if message == "setup warning" + )); + assert!(matches!( + second_events.recv().await, + Some(AgentRunEvent::Finished(AgentRunTerminal::Failed)) + )); + assert!(second_events.try_recv().is_err()); + assert!(matches!( + external_events.recv().await, + Some(AgentRunEvent::Started) + )); + + let emitted = sink + .events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(emitted.len(), 4); + assert!(matches!( + &emitted[0], + AgentServiceEvent::Run { session_id, run_id, event: AgentRunEvent::Started } + if session_id == "session-1" && run_id == "run-1" + )); + assert!(matches!( + &emitted[1], + AgentServiceEvent::Run { + session_id, + run_id, + event: AgentRunEvent::SetupWarning(message), + } if session_id == "session-2" && run_id == "run-2" && message == "setup warning" + )); + assert!(matches!( + &emitted[2], + AgentServiceEvent::Run { + event: AgentRunEvent::Finished(AgentRunTerminal::Completed), + .. + } + )); + assert!(matches!( + &emitted[3], + AgentServiceEvent::Run { + event: AgentRunEvent::Finished(AgentRunTerminal::Failed), + .. + } + )); + } + + #[tokio::test] + async fn lagged_run_event_consumer_never_backpressures_the_agent() { + let (publisher, events) = AgentRunEventPublisher::new( + AgentEventDispatcher::new(Arc::new(NoopAgentEventSink)), + "session-1".to_string(), "run-1".to_string(), - ActiveAgentRun { - token: CancellationToken::new(), - session_id: "session-1".to_string(), - cancelled_permission_ids: Arc::new(Mutex::new(HashSet::new())), - task_handle, + AgentHostEventPolicy::Suppress, + ); + let overflowed = publisher.overflow_flag(); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + for _ in 0..(AGENT_RUN_EVENT_CAPACITY + 32) { + publisher.publish(AgentRunEvent::Started).await; + } + publisher + .publish(AgentRunEvent::Finished(AgentRunTerminal::Completed)) + .await; + }) + .await + .expect("a full protocol queue must not block the Agent run"); + + assert_eq!(events.len(), AGENT_RUN_EVENT_CAPACITY); + assert!(overflowed.load(Ordering::Acquire)); + } + + #[test] + fn stale_tool_context_identity_cannot_remove_its_replacement() { + let original = SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + BTreeMap::from([("TOKEN".to_string(), "original".to_string())]), + BTreeSet::from(["TOKEN".to_string()]), + true, + ) + .unwrap(), + ); + let replacement = SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + BTreeMap::from([("TOKEN".to_string(), "replacement".to_string())]), + BTreeSet::from(["TOKEN".to_string()]), + true, + ) + .unwrap(), + ); + let mut contexts = HashMap::from([( + "session-1".to_string(), + InstalledAgentToolContext { + installation_id: 2, + context: replacement.clone(), + owner: AgentToolContextOwner::Leased, }, + )]); + + assert!(take_matching_tool_context(&mut contexts, "session-1", 1, &original).is_none()); + assert_eq!( + contexts["session-1"].context.snapshot().values["TOKEN"], + "replacement" + ); + + let removed = take_matching_tool_context(&mut contexts, "session-1", 2, &replacement) + .expect("the exact replacement lease should remove its context"); + assert!(removed.context.ptr_eq(&replacement)); + assert!(contexts.is_empty()); + } + + #[test] + fn leased_tool_context_requires_the_exact_surface_capability() { + let leased = SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + BTreeMap::from([("TOKEN".to_string(), "leased-secret".to_string())]), + BTreeSet::from(["TOKEN".to_string()]), + true, + ) + .unwrap(), ); + let mut contexts = HashMap::from([( + "session-1".to_string(), + InstalledAgentToolContext { + installation_id: 7, + context: leased.clone(), + owner: AgentToolContextOwner::Leased, + }, + )]); + let access = AgentToolContextAccess { + account_scope: Arc::from("account-1"), + session_id: Arc::from("session-1"), + installation_id: 7, + context: leased.clone(), + }; - assert!(has_active_session_run(&active_runs, "session-1")); - assert!(!has_active_session_run(&active_runs, "session-2")); + assert!(resolve_session_tool_context( + &mut contexts, + "account-1", + "session-1", + None, + &AgentToolContextSpec::default(), + ) + .is_err()); + assert!(resolve_session_tool_context( + &mut contexts, + "account-1", + "session-1", + Some(&access), + &AgentToolContextSpec::default(), + ) + .unwrap() + .ptr_eq(&leased)); + + leased.revoke(); + let local = resolve_session_tool_context( + &mut contexts, + "account-1", + "session-1", + None, + &AgentToolContextSpec::default(), + ) + .expect("a revoked external context should be recoverable as a local task"); + assert!(!local.ptr_eq(&leased)); + assert_eq!(contexts["session-1"].owner, AgentToolContextOwner::Maple); + } + + #[test] + fn uncommitted_tool_context_installation_revokes_synchronously() { + let context = SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]), + BTreeSet::from(["TOKEN".to_string()]), + true, + ) + .unwrap(), + ); + { + let _pending = PendingAgentToolContextInstallation::new(context.clone()); + } + assert!(context.is_revoked()); + assert!(context.snapshot().values.is_empty()); } #[test] @@ -8599,23 +10318,54 @@ mod tests { assert!(ensure_account_scope(&first, &first).is_ok()); assert!(ensure_account_scope(&first, &second).is_err()); } - #[tokio::test] async fn rejects_operations_captured_before_account_clear() { - let state = AgentRuntimeState::new(); + let state = MapleAgentService::new(MapleAgentHostResources::new( + AgentPathLayout::from_app_roots( + PathBuf::from("unused-config-root"), + PathBuf::from("unused-local-root"), + ), + Arc::new(NoopAgentEventSink), + AgentToolContextSpec::default(), + )); + let stale_handle = state.handle_for_user("user-to-clear").await.unwrap(); let scope = account_scope("user-to-clear").unwrap(); - let stale_generation = account_generation(&state, &scope).await; - let current_generation = advance_account_generation(&state, &scope).await; + advance_account_generation(&state, &scope).await; + let current_handle = state.handle_for_user("user-to-clear").await.unwrap(); - assert!(ensure_account_generation(&state, &scope, stale_generation) - .await - .is_err()); - assert!( - ensure_account_generation(&state, &scope, current_generation) - .await - .is_ok() + assert!(stale_handle.verify_generation().await.is_err()); + assert!(current_handle.verify_generation().await.is_ok()); + } + + #[tokio::test] + async fn service_drain_rejects_new_work_and_failed_update_can_reopen_it() { + let state = MapleAgentService::new(MapleAgentHostResources::new( + AgentPathLayout::from_app_roots( + PathBuf::from("unused-config-root"), + PathBuf::from("unused-local-root"), + ), + Arc::new(NoopAgentEventSink), + AgentToolContextSpec::default(), + )); + let handle = state.handle_for_user("user-during-shutdown").await.unwrap(); + + assert!(state.ensure_accepting_new_work().is_ok()); + assert!(handle.ensure_accepting_new_work().is_ok()); + + state.begin_draining(); + assert_eq!( + state.ensure_accepting_new_work().unwrap_err(), + AGENT_SERVICE_DRAINING_ERROR + ); + assert_eq!( + handle.ensure_accepting_new_work().unwrap_err(), + AGENT_SERVICE_DRAINING_ERROR ); + + state.reopen_after_failed_shutdown(); + assert!(state.ensure_accepting_new_work().is_ok()); + assert!(handle.ensure_accepting_new_work().is_ok()); } #[test] @@ -8638,7 +10388,7 @@ mod tests { let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (started_tx, started_rx) = oneshot::channel(); let task_dropped = Arc::clone(&dropped); - let task = tauri::async_runtime::spawn(async move { + let task = tokio::spawn(async move { let _drop_flag = DropFlag(task_dropped); let _ = started_tx.send(()); futures_util::future::pending::<()>().await; @@ -8663,32 +10413,223 @@ mod tests { assert!(!title.contains(" ")); } + #[test] + fn permission_extraction_rejects_empty_and_conflicting_ids() { + let status_arguments = serde_json::Map::from_iter([( + "command".to_string(), + Value::String("git status --short".to_string()), + )]); + let push_arguments = serde_json::Map::from_iter([( + "command".to_string(), + Value::String("git push".to_string()), + )]); + let message = Message::assistant() + .with_content(MessageContent::action_required( + "request-1", + "shell".to_string(), + status_arguments, + None, + )) + .with_content(MessageContent::action_required( + "request-1", + "shell".to_string(), + push_arguments, + None, + )) + .with_content(MessageContent::action_required( + "", + "shell".to_string(), + serde_json::Map::new(), + None, + )); + + let extracted = tool_permission_requests(&message); + + assert!(extracted.requests.is_empty()); + assert_eq!( + extracted.conflicting_ids, + HashSet::from(["request-1".to_string(), String::new()]) + ); + } + + #[test] + fn permission_extraction_rejects_identical_duplicate_ids() { + let arguments = serde_json::Map::from_iter([( + "command".to_string(), + Value::String("git status --short".to_string()), + )]); + let message = Message::assistant() + .with_content(MessageContent::action_required( + "request-1", + "shell".to_string(), + arguments.clone(), + None, + )) + .with_content(MessageContent::action_required( + "request-1", + "shell".to_string(), + arguments, + None, + )); + + let extracted = tool_permission_requests(&message); + + assert!(extracted.requests.is_empty()); + assert_eq!( + extracted.conflicting_ids, + HashSet::from(["request-1".to_string()]) + ); + } + #[tokio::test] async fn cancelled_permission_is_not_registered() { let pending = Arc::new(Mutex::new(HashMap::new())); + let issued = Arc::new(Mutex::new(HashSet::new())); let cancel_token = CancellationToken::new(); cancel_token.cancel(); - assert!( - !register_pending_permission(&pending, "request-1", "session-1", &cancel_token).await + assert_eq!( + register_pending_permission( + &pending, + &issued, + "session-1", + "run-1", + AgentPermissionRouting::Desktop, + test_permission_request("request-1"), + &cancel_token, + ) + .await, + PendingPermissionRegistration::Rejected ); assert!(pending.lock().await.is_empty()); } #[tokio::test] - async fn pending_permission_ids_are_scoped_by_session() { + async fn pending_permissions_are_taken_only_for_the_exact_run() { let pending = Arc::new(Mutex::new(HashMap::from([ - (("session-1".to_string(), "shared-request".to_string()), ()), - (("session-2".to_string(), "shared-request".to_string()), ()), + ( + ("session-1".to_string(), "request-1".to_string()), + test_pending_permission("run-1", AgentPermissionRouting::Desktop, "request-1"), + ), + ( + ("session-1".to_string(), "request-2".to_string()), + test_pending_permission( + "run-2", + AgentPermissionRouting::CallingSurface, + "request-2", + ), + ), ]))); - let selected = pending_permissions_for_sessions(&pending, &["session-1".to_string()]).await; + let selected = take_pending_permissions_for_runs(&pending, &["run-1".to_string()]).await; + + assert_eq!(selected.len(), 1); + assert_eq!( + selected[0].0, + ("session-1".to_string(), "request-1".to_string()) + ); + assert_eq!(selected[0].1.run_id, "run-1"); + let remaining = pending.lock().await; + assert_eq!(remaining.len(), 1); + assert_eq!(remaining.values().next().unwrap().run_id, "run-2"); + } + + #[tokio::test] + async fn conflicting_permission_registration_invalidates_the_stale_capability() { + let pending = Arc::new(Mutex::new(HashMap::new())); + let issued = Arc::new(Mutex::new(HashSet::new())); + let cancel_token = CancellationToken::new(); + let original = test_permission_request("request-1"); + assert_eq!( + register_pending_permission( + &pending, + &issued, + "session-1", + "run-1", + AgentPermissionRouting::CallingSurface, + original.clone(), + &cancel_token, + ) + .await, + PendingPermissionRegistration::Registered + ); + assert_eq!( + register_pending_permission( + &pending, + &issued, + "session-1", + "run-1", + AgentPermissionRouting::CallingSurface, + original, + &cancel_token, + ) + .await, + PendingPermissionRegistration::Existing + ); + + let mut conflicting = test_permission_request("request-1"); + conflicting + .arguments + .insert("command".to_string(), Value::String("git push".to_string())); + assert_eq!( + register_pending_permission( + &pending, + &issued, + "session-1", + "run-1", + AgentPermissionRouting::CallingSurface, + conflicting, + &cancel_token, + ) + .await, + PendingPermissionRegistration::Rejected + ); + assert!(pending.lock().await.is_empty()); + } + + #[tokio::test] + async fn resolved_permission_ids_cannot_be_reissued_within_a_run() { + let pending = Arc::new(Mutex::new(HashMap::new())); + let issued = Arc::new(Mutex::new(HashSet::new())); + let cancel_token = CancellationToken::new(); + assert_eq!( + register_pending_permission( + &pending, + &issued, + "session-1", + "run-1", + AgentPermissionRouting::Desktop, + test_permission_request("request-1"), + &cancel_token, + ) + .await, + PendingPermissionRegistration::Registered + ); + assert_eq!( + take_pending_permissions_for_runs(&pending, &["run-1".to_string()]) + .await + .len(), + 1 + ); + let mut reused = test_permission_request("request-1"); + reused + .arguments + .insert("command".to_string(), Value::String("git push".to_string())); assert_eq!( - selected, - vec![("shared-request".to_string(), "session-1".to_string())] + register_pending_permission( + &pending, + &issued, + "session-1", + "run-1", + AgentPermissionRouting::Desktop, + reused, + &cancel_token, + ) + .await, + PendingPermissionRegistration::Rejected ); - assert_eq!(pending.lock().await.len(), 2); + assert!(pending.lock().await.is_empty()); } #[test] diff --git a/frontend/src-tauri/src/agent/developer_tools.rs b/frontend/src-tauri/src/agent/developer_tools.rs index 324c476c9..aabd04932 100644 --- a/frontend/src-tauri/src/agent/developer_tools.rs +++ b/frontend/src-tauri/src/agent/developer_tools.rs @@ -28,6 +28,8 @@ use rmcp::model::{ }; use rmcp::object; use serde::{de::Error as SerdeDeError, Deserialize, Deserializer}; +#[cfg(test)] +use std::collections::{BTreeMap, BTreeSet}; use std::collections::{HashMap, HashSet}; use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; @@ -45,6 +47,7 @@ use tokio_util::sync::CancellationToken; use windows::Win32::System::Threading::CREATE_NO_WINDOW; use super::shell_permission::{is_remote_file_source, thinking_disabled_request_params}; +use super::tool_context::{AgentToolContextSnapshot, SharedAgentToolContext}; const MAX_READ_LINES: usize = 2_000; const MAX_READ_BYTES: usize = 50 * 1024; @@ -132,6 +135,7 @@ pub(crate) struct MapleDeveloperClient { goose: DeveloperClient, web_transport: Arc, web_state: Arc, + tool_context: SharedAgentToolContext, contextual_image_context: Option, #[cfg(not(windows))] login_path_probe: ShellTool, @@ -145,6 +149,7 @@ impl MapleDeveloperClient { primary_model_supports_vision: bool, web_transport: Arc, web_state: Arc, + tool_context: SharedAgentToolContext, ) -> 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 +165,7 @@ impl MapleDeveloperClient { goose: DeveloperClient::new(context)?, web_transport, web_state, + tool_context, contextual_image_context, #[cfg(not(windows))] login_path_probe: ShellTool::new(true)?, @@ -478,11 +484,13 @@ impl McpClientTrait for MapleDeveloperClient { let login_path = self.login_path().await; #[cfg(windows)] let login_path: Option = None; + let tool_context = self.tool_context.snapshot(); return Ok(run_bounded_shell( params, working_dir, login_path.as_deref(), Some(&ctx.session_id), + &tool_context, cancel_token, ) .await); @@ -811,11 +819,69 @@ struct BoundedShellExecution { cancelled: bool, } +/// Keeps OS containment armed across every await in shell execution. +/// +/// Tokio may drop the whole prompt future during forced runtime shutdown. Raw +/// `Command::kill_on_drop` only reaches the shell leader; the process-wrap +/// child reaches the Unix process group or Windows job from this synchronous +/// drop path as well. +struct ArmedShellChild { + child: Box, + armed: bool, +} + +impl ArmedShellChild { + fn new(child: Box) -> Self { + Self { child, armed: true } + } + + fn as_mut(&mut self) -> &mut dyn ChildWrapper { + self.child.as_mut() + } + + async fn kill_and_wait(&mut self) -> Option { + let kill_succeeded = match self.child.start_kill() { + Ok(()) => true, + Err(error) => { + log::warn!("Failed to terminate shell containment unit: {error}"); + false + } + }; + match self.child.wait().await { + Ok(status) => { + if kill_succeeded { + self.armed = false; + } + status.code() + } + Err(error) => { + log::warn!("Failed to reap shell containment unit: {error}"); + None + } + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for ArmedShellChild { + fn drop(&mut self) { + if self.armed { + if let Err(error) = self.child.start_kill() { + log::warn!("Failed to terminate dropped shell containment unit: {error}"); + } + } + } +} + async fn run_bounded_shell( params: ShellParams, working_dir: Option<&Path>, login_path: Option<&str>, session_id: Option<&str>, + tool_context: &AgentToolContextSnapshot, cancel_token: CancellationToken, ) -> CallToolResult { if params.command.trim().is_empty() { @@ -829,6 +895,7 @@ async fn run_bounded_shell( working_dir, login_path, session_id, + tool_context, cancel_token, ) .await @@ -853,10 +920,22 @@ async fn execute_bounded_shell( working_dir: Option<&Path>, login_path: Option<&str>, session_id: Option<&str>, + tool_context: &AgentToolContextSnapshot, cancel_token: CancellationToken, ) -> Result { - let mut command = - build_bounded_shell_command(command_line, working_dir, login_path, session_id); + let ephemeral = tool_context.ephemeral; + #[cfg(not(windows))] + if Path::new("/.flatpak-info").exists() && ephemeral { + return Err("Ephemeral Agent tool contexts are not supported inside Flatpak".to_string()); + } + let launch_guard = tool_context.begin_process_launch(&cancel_token)?; + let mut command = build_bounded_shell_command( + command_line, + working_dir, + login_path, + session_id, + tool_context, + ); command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -876,14 +955,19 @@ async fn execute_bounded_shell( command.wrap(CreationFlags(CREATE_NO_WINDOW)); command.wrap(JobObject); } - let mut child = command - .spawn() - .map_err(|error| format!("Failed to spawn shell command: {error}"))?; + let mut child = ArmedShellChild::new( + command + .spawn() + .map_err(|error| format!("Failed to spawn shell command: {error}"))?, + ); + drop(launch_guard); let stdout = child + .as_mut() .stdout() .take() .ok_or_else(|| "Failed to capture shell stdout".to_string())?; let stderr = child + .as_mut() .stderr() .take() .ok_or_else(|| "Failed to capture shell stderr".to_string())?; @@ -913,46 +997,77 @@ async fn execute_bounded_shell( biased; _ = cancel_token.cancelled() => { cancelled = true; - terminate_shell_process(child.as_mut()).await + terminate_shell_process(&mut child).await + } + _ = tool_context.revoked.cancelled() => { + cancelled = true; + terminate_shell_process(&mut child).await } _ = output_limit_reached.cancelled() => { - terminate_shell_process(child.as_mut()).await + terminate_shell_process(&mut child).await } _ = &mut timeout => { timed_out = true; - terminate_shell_process(child.as_mut()).await + terminate_shell_process(&mut child).await } result = wait_for_shell_parent(child.as_mut()) => match result { Ok(status) => status.code(), Err(error) => { wait_error = Some(format!("Failed waiting on shell command: {error}")); - terminate_shell_process(child.as_mut()).await + terminate_shell_process(&mut child).await } }, }; - let mut capture = - match tokio::time::timeout(SHELL_OUTPUT_DRAIN_TIMEOUT, &mut capture_task).await { - Ok(Ok(capture)) => capture, - Ok(Err(error)) => BoundedShellCapture { - collection_error: Some(format!("Shell output task failed: {error}")), - ..BoundedShellCapture::default() - }, - Err(_) => { - stdout_task.abort(); - stderr_task.abort(); - match capture_task.await { - Ok(mut capture) => { - capture.drain_truncated = true; - capture - } - Err(_) => BoundedShellCapture { - drain_truncated: true, - ..BoundedShellCapture::default() - }, + // Credential-bearing contexts cannot allow a descendant to outlive the + // shell parent even during output draining. On Unix this reaches the + // current process group; on Windows it reaches the assigned Job Object. + if ephemeral && child.armed { + let _ = terminate_shell_process(&mut child).await; + } + + let drain_timeout = tokio::time::sleep(SHELL_OUTPUT_DRAIN_TIMEOUT); + tokio::pin!(drain_timeout); + let capture_result = tokio::select! { + biased; + _ = cancel_token.cancelled(), if !cancelled => { + cancelled = true; + let _ = terminate_shell_process(&mut child).await; + Some(tokio::time::timeout(SHELL_OUTPUT_DRAIN_TIMEOUT, &mut capture_task).await) + } + _ = tool_context.revoked.cancelled(), if !cancelled => { + cancelled = true; + let _ = terminate_shell_process(&mut child).await; + Some(tokio::time::timeout(SHELL_OUTPUT_DRAIN_TIMEOUT, &mut capture_task).await) + } + _ = output_limit_reached.cancelled() => { + let _ = terminate_shell_process(&mut child).await; + Some(tokio::time::timeout(SHELL_OUTPUT_DRAIN_TIMEOUT, &mut capture_task).await) + } + result = &mut capture_task => Some(Ok(result)), + _ = &mut drain_timeout => None, + }; + let mut capture = match capture_result { + Some(Ok(Ok(capture))) => capture, + Some(Ok(Err(error))) => BoundedShellCapture { + collection_error: Some(format!("Shell output task failed: {error}")), + ..BoundedShellCapture::default() + }, + Some(Err(_)) | None => { + stdout_task.abort(); + stderr_task.abort(); + match capture_task.await { + Ok(mut capture) => { + capture.drain_truncated = true; + capture } + Err(_) => BoundedShellCapture { + drain_truncated: true, + ..BoundedShellCapture::default() + }, } - }; + } + }; stdout_task.abort(); stderr_task.abort(); let _ = stdout_task.await; @@ -961,15 +1076,18 @@ async fn execute_bounded_shell( capture.exceeded_limit = true; } // The top-level shell can exit before a noisy background descendant reaches - // 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 { - let _ = terminate_shell_process(child.as_mut()).await; + // the cap. Keep the wrapped containment handle alive and kill the process + // group/job even if the parent wait already completed. A quiet ordinary + // background job follows Pi/Goose semantics and is left running. + if capture.exceeded_limit && child.armed { + let _ = terminate_shell_process(&mut child).await; } if let Some(error) = wait_error { return Err(error); } + if !timed_out && !cancelled && !capture.exceeded_limit && !ephemeral { + child.disarm(); + } Ok(BoundedShellExecution { capture, @@ -1056,12 +1174,10 @@ async fn collect_bounded_shell_output( capture } -async fn terminate_shell_process(child: &mut dyn ChildWrapper) -> Option { - // ProcessGroup and JobObject both override start_kill to terminate the - // complete descendant tree. Their wait implementations retain the - // top-level exit status and finish reaping the wrapped process container. - let _ = child.start_kill(); - child.wait().await.ok().and_then(|status| status.code()) +async fn terminate_shell_process(child: &mut ArmedShellChild) -> Option { + // ProcessGroup and JobObject both override start_kill. On Unix this + // terminates the current process group; on Windows the assigned job. + child.kill_and_wait().await } fn build_bounded_shell_command( @@ -1069,6 +1185,7 @@ fn build_bounded_shell_command( working_dir: Option<&Path>, login_path: Option<&str>, session_id: Option<&str>, + tool_context: &AgentToolContextSnapshot, ) -> tokio::process::Command { #[cfg(windows)] let mut command = { @@ -1096,6 +1213,7 @@ fn build_bounded_shell_command( if let Some(path) = login_path { command.env("PATH", path); } + apply_tool_context(&mut command, tool_context); command }; @@ -1117,6 +1235,7 @@ fn build_bounded_shell_command( command.arg(format!("--env=PATH={path}")); } apply_flatpak_session_environment(&mut command, session_id); + apply_flatpak_tool_context(&mut command, tool_context); command.arg(shell).args(["-c", command_line]); command } else { @@ -1129,6 +1248,7 @@ fn build_bounded_shell_command( command.env("PATH", path); } apply_session_environment(&mut command, session_id); + apply_tool_context(&mut command, tool_context); command } }; @@ -1149,6 +1269,16 @@ fn apply_session_environment(command: &mut tokio::process::Command, session_id: } } +fn apply_tool_context( + command: &mut tokio::process::Command, + tool_context: &AgentToolContextSnapshot, +) { + for key in &tool_context.scrub_from_parent { + command.env_remove(key); + } + command.envs(&tool_context.values); +} + #[cfg(not(windows))] fn apply_flatpak_session_environment( command: &mut tokio::process::Command, @@ -1161,6 +1291,19 @@ fn apply_flatpak_session_environment( } } +#[cfg(not(windows))] +fn apply_flatpak_tool_context( + command: &mut tokio::process::Command, + tool_context: &AgentToolContextSnapshot, +) { + for key in &tool_context.scrub_from_parent { + command.arg(format!("--unset-env={key}")); + } + for (key, value) in &tool_context.values { + command.arg(format!("--env={key}={value}")); + } +} + #[cfg(not(windows))] fn executable_on_path(name: &str) -> Option { std::env::var_os("PATH") @@ -1854,13 +1997,14 @@ async fn write_file( ) -> CallToolResult { let path = resolve_path(¶ms.path, working_dir); let lock = mutation_lock(&path); - let _guard = tokio::select! { + let guard = tokio::select! { biased; _ = cancel_token.cancelled() => return error_result("Write cancelled"), - guard = lock.lock() => guard, + guard = lock.lock_owned() => guard, }; let worker_cancel_token = cancel_token.clone(); match tokio::task::spawn_blocking(move || { + let _guard = guard; write_file_blocking(params, path, worker_cancel_token) }) .await @@ -1928,14 +2072,16 @@ async fn edit_file( let path = resolve_path(¶ms.path, working_dir); let lock = mutation_lock(&path); - let _guard = tokio::select! { + let guard = tokio::select! { biased; _ = cancel_token.cancelled() => return error_result("Edit cancelled"), - guard = lock.lock() => guard, + guard = lock.lock_owned() => guard, }; let worker_cancel_token = cancel_token.clone(); - let task = - tokio::task::spawn_blocking(move || edit_file_blocking(params, path, worker_cancel_token)); + let task = tokio::task::spawn_blocking(move || { + let _guard = guard; + edit_file_blocking(params, path, worker_cancel_token) + }); tokio::select! { biased; _ = cancel_token.cancelled() => error_result("Edit cancelled"), @@ -2202,6 +2348,7 @@ fn mutation_key(path: &Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use crate::agent::tool_context::AgentToolContextSpec; use goose::config::GooseMode; use goose::providers::base::{ProviderUsage, Usage}; use goose::session::SessionManager; @@ -2253,15 +2400,46 @@ 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()), + test_tool_context(tool_environment, BTreeSet::new(), false), ) .unwrap() } + fn test_tool_context( + values: BTreeMap, + scrub_from_parent: BTreeSet, + ephemeral: bool, + ) -> SharedAgentToolContext { + SharedAgentToolContext::new( + AgentToolContextSpec::try_new(values, scrub_from_parent, ephemeral).unwrap(), + ) + } + + fn test_tool_context_snapshot( + values: BTreeMap, + scrub_from_parent: BTreeSet, + ephemeral: bool, + ) -> AgentToolContextSnapshot { + test_tool_context(values, scrub_from_parent, ephemeral).snapshot() + } + + fn empty_tool_context_snapshot() -> AgentToolContextSnapshot { + test_tool_context_snapshot(BTreeMap::new(), BTreeSet::new(), false) + } + struct TestDir(PathBuf); impl TestDir { @@ -2386,6 +2564,7 @@ mod tests { true, Arc::new(TestWebTransport), Arc::new(WebToolState::default()), + test_tool_context(BTreeMap::new(), BTreeSet::new(), false), ) .unwrap(); let config = goose::agents::ExtensionConfig::Builtin { @@ -2904,6 +3083,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn shell_stops_processes_at_the_combined_output_limit() { + let tool_context = empty_tool_context_snapshot(); let result = tokio::time::timeout( Duration::from_secs(5), run_bounded_shell( @@ -2914,6 +3094,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &tool_context, CancellationToken::new(), ), ) @@ -2958,6 +3139,77 @@ 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 tool_context = test_tool_context_snapshot( + environment.clone(), + environment.keys().cloned().collect(), + true, + ); + let mut command = tokio::process::Command::new("unused"); + apply_tool_context(&mut command, &tool_context); + + 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 tool_context = test_tool_context_snapshot( + environment, + BTreeSet::from([ + "BUZZ_RELAY_URL".to_string(), + "BUZZ_PRIVATE_KEY".to_string(), + "BUZZ_AUTH_TAG".to_string(), + "BUZZ_API_TOKEN".to_string(), + "BUZZ_ACP_DISPLAY_NAME".to_string(), + ]), + true, + ); + let mut command = tokio::process::Command::new("flatpak-spawn"); + apply_flatpak_tool_context(&mut command, &tool_context); + let arguments = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + arguments, + vec![ + "--unset-env=BUZZ_ACP_DISPLAY_NAME", + "--unset-env=BUZZ_API_TOKEN", + "--unset-env=BUZZ_AUTH_TAG", + "--unset-env=BUZZ_PRIVATE_KEY", + "--unset-env=BUZZ_RELAY_URL", + "--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,12 +3234,102 @@ 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_context = test_tool_context( + BTreeMap::from([( + "BUZZ_AUTH_TAG".to_string(), + "maple-session-auth-tag".to_string(), + )]), + BTreeSet::from(["BUZZ_AUTH_TAG".to_string()]), + true, + ); + let client = MapleDeveloperClient::new( + test_context(temp.path().join("sessions")), + true, + Arc::new(TestWebTransport), + Arc::new(WebToolState::default()), + tool_context.clone(), + ) + .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_context.revoke(); + 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() { let temp = TestDir::new(); let sentinel = temp.path().join("background-completed"); let command = format!("(sleep 1; printf survived > '{}') &", sentinel.display()); + let tool_context = empty_tool_context_snapshot(); let result = tokio::time::timeout( Duration::from_secs(3), @@ -2999,6 +3341,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &tool_context, CancellationToken::new(), ), ) @@ -3014,6 +3357,50 @@ 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 tool_context = test_tool_context_snapshot( + environment, + BTreeSet::from(["BUZZ_AUTH_TAG".to_string()]), + true, + ); + + 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, + &tool_context, + 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.timed_out); + + 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() { @@ -3023,6 +3410,7 @@ mod tests { "((while :; do printf 1234567890; done) & sleep 1; printf survived > '{}') &", sentinel.display() ); + let tool_context = empty_tool_context_snapshot(); let result = tokio::time::timeout( Duration::from_secs(3), @@ -3034,6 +3422,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &tool_context, CancellationToken::new(), ), ) @@ -3054,13 +3443,14 @@ mod tests { #[cfg(unix)] #[tokio::test] - async fn shell_timeout_kills_the_complete_process_tree() { + async fn shell_timeout_kills_the_unix_process_group() { let temp = TestDir::new(); let sentinel = temp.path().join("timed-out-descendant-survived"); let command = format!( "(sleep 2; printf survived > '{}') & sleep 5", sentinel.display() ); + let tool_context = empty_tool_context_snapshot(); let result = run_bounded_shell( ShellParams { command, @@ -3069,6 +3459,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &tool_context, CancellationToken::new(), ) .await; @@ -3080,13 +3471,13 @@ mod tests { tokio::time::sleep(Duration::from_secs(1)).await; assert!( !sentinel.exists(), - "timed-out descendant survived process-tree termination" + "timed-out descendant survived process-group termination" ); } #[cfg(unix)] #[tokio::test] - async fn shell_cancellation_kills_the_complete_process_tree() { + async fn shell_cancellation_kills_the_unix_process_group() { let temp = TestDir::new(); let sentinel = temp.path().join("cancelled-descendant-survived"); let command = format!( @@ -3099,6 +3490,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(100)).await; cancellation.cancel(); }); + let tool_context = empty_tool_context_snapshot(); let result = run_bounded_shell( ShellParams { command, @@ -3107,6 +3499,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &tool_context, cancel_token, ) .await; @@ -3117,13 +3510,189 @@ mod tests { tokio::time::sleep(Duration::from_secs(1)).await; assert!( !sentinel.exists(), - "cancelled descendant survived process-tree termination" + "cancelled descendant survived process-group termination" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn tool_context_revocation_kills_the_unix_process_group() { + let temp = TestDir::new(); + let sentinel = temp.path().join("revoked-context-descendant-survived"); + let command = format!( + "(sleep 1; printf survived > '{}') & sleep 5", + sentinel.display() + ); + let shared_context = test_tool_context(BTreeMap::new(), BTreeSet::new(), false); + let tool_context = shared_context.snapshot(); + let revocation = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + shared_context.revoke(); + }); + + let result = run_bounded_shell( + ShellParams { + command, + timeout_secs: Some(4), + }, + None, + std::env::var("PATH").ok().as_deref(), + None, + &tool_context, + CancellationToken::new(), + ) + .await; + revocation.await.unwrap(); + assert_eq!(result.is_error, Some(true)); + assert!(text(&result).contains("Command cancelled")); + + tokio::time::sleep(Duration::from_secs(1)).await; + assert!( + !sentinel.exists(), + "revoked tool-context descendant survived process-group termination" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn pre_cancelled_shell_cannot_launch_a_process() { + let temp = TestDir::new(); + let sentinel = temp.path().join("pre-cancelled-shell-launched"); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = run_bounded_shell( + ShellParams { + command: format!("printf launched > '{}'", sentinel.display()), + timeout_secs: Some(2), + }, + None, + std::env::var("PATH").ok().as_deref(), + None, + &empty_tool_context_snapshot(), + cancel_token, + ) + .await; + + assert_eq!(result.is_error, Some(true)); + assert!(text(&result).contains("cancelled before command launch")); + assert!(!sentinel.exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn aborting_the_shell_task_kills_its_unix_process_group() { + let temp = TestDir::new(); + let started = temp.path().join("abort-shell-started"); + let sentinel = temp.path().join("abort-descendant-survived"); + let command = format!( + "printf started > '{}'; (sleep 1; printf survived > '{}') & sleep 5", + started.display(), + sentinel.display() + ); + let tool_context = test_tool_context_snapshot(BTreeMap::new(), BTreeSet::new(), true); + let task = tokio::spawn(async move { + run_bounded_shell( + ShellParams { + command, + timeout_secs: Some(10), + }, + None, + std::env::var("PATH").ok().as_deref(), + None, + &tool_context, + CancellationToken::new(), + ) + .await + }); + + tokio::time::timeout(Duration::from_secs(2), async { + while !started.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("shell should start before forced task abort"); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + + tokio::time::sleep(Duration::from_secs(1)).await; + assert!( + !sentinel.exists(), + "forced task abort left a same-group descendant running" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn revocation_after_parent_exit_interrupts_output_drain() { + let temp = TestDir::new(); + let sentinel = temp.path().join("draining-descendant-survived"); + let shared_context = test_tool_context(BTreeMap::new(), BTreeSet::new(), false); + let tool_context = shared_context.snapshot(); + let revocation = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + shared_context.revoke(); + }); + + let result = run_bounded_shell( + ShellParams { + command: format!("(sleep 1; printf survived > '{}') &", sentinel.display()), + timeout_secs: Some(3), + }, + None, + std::env::var("PATH").ok().as_deref(), + None, + &tool_context, + CancellationToken::new(), + ) + .await; + revocation.await.unwrap(); + + assert_eq!(result.is_error, Some(true)); + assert!(text(&result).contains("Command cancelled")); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!( + !sentinel.exists(), + "revocation during output drain left a same-group descendant running" ); } + #[cfg(unix)] + #[tokio::test] + async fn snapshot_captured_before_revocation_cannot_launch_a_process() { + let temp = TestDir::new(); + let sentinel = temp.path().join("revoked-context-launched"); + let shared_context = test_tool_context( + BTreeMap::from([("PRIVATE_VALUE".to_string(), "secret".to_string())]), + BTreeSet::from(["PRIVATE_VALUE".to_string()]), + true, + ); + let tool_context = shared_context.snapshot(); + shared_context.revoke(); + + let result = run_bounded_shell( + ShellParams { + command: format!("printf launched > '{}'", sentinel.display()), + timeout_secs: Some(2), + }, + None, + std::env::var("PATH").ok().as_deref(), + None, + &tool_context, + CancellationToken::new(), + ) + .await; + + assert_eq!(result.is_error, Some(true)); + assert!(text(&result).contains("revoked before command launch")); + assert!(!sentinel.exists()); + } + #[cfg(unix)] #[tokio::test] async fn shell_preserves_small_successful_output() { + let tool_context = empty_tool_context_snapshot(); let result = run_bounded_shell( ShellParams { command: "printf hello".to_string(), @@ -3132,6 +3701,7 @@ mod tests { None, std::env::var("PATH").ok().as_deref(), None, + &tool_context, CancellationToken::new(), ) .await; diff --git a/frontend/src-tauri/src/agent/shell_permission.rs b/frontend/src-tauri/src/agent/shell_permission.rs index 925c30742..62355bcc9 100644 --- a/frontend/src-tauri/src/agent/shell_permission.rs +++ b/frontend/src-tauri/src/agent/shell_permission.rs @@ -571,7 +571,7 @@ mod tests { request_params.get("chat_template_kwargs"), Some(&serde_json::json!({ "enable_thinking": false })) ); - assert!(request_params.get("thinking_effort").is_none()); + assert!(!request_params.contains_key("thinking_effort")); } #[tokio::test] diff --git a/frontend/src-tauri/src/agent/tool_context.rs b/frontend/src-tauri/src/agent/tool_context.rs new file mode 100644 index 000000000..83df4da1d --- /dev/null +++ b/frontend/src-tauri/src/agent/tool_context.rs @@ -0,0 +1,260 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex, MutexGuard, RwLock}; +use tokio_util::sync::CancellationToken; + +const MAX_TOOL_CONTEXT_KEYS: usize = 16; +const MAX_TOOL_CONTEXT_KEY_BYTES: usize = 64; +const MAX_TOOL_CONTEXT_VALUE_BYTES: usize = 16 * 1024; +const MAX_TOOL_CONTEXT_TOTAL_BYTES: usize = 32 * 1024; + +/// Validated, transport-neutral context supplied to Maple's developer tools. +/// +/// Protocol adapters own their allowlists and decide which values make an +/// invocation ephemeral. The Agent service owns only validation, installation, +/// and revocation. +#[derive(Clone, Default)] +pub(crate) struct AgentToolContextSpec { + values: BTreeMap, + scrub_from_parent: BTreeSet, + ephemeral: bool, +} + +impl AgentToolContextSpec { + pub(crate) fn try_new( + values: BTreeMap, + scrub_from_parent: BTreeSet, + ephemeral: bool, + ) -> Result { + if values.len() > MAX_TOOL_CONTEXT_KEYS || scrub_from_parent.len() > MAX_TOOL_CONTEXT_KEYS { + return Err(format!( + "Agent tool context supports at most {MAX_TOOL_CONTEXT_KEYS} variables" + )); + } + + let mut total_bytes = 0usize; + for key in values.keys().chain(scrub_from_parent.iter()) { + validate_key(key)?; + } + for (key, value) in &values { + if value.contains('\0') { + return Err(format!( + "Agent tool context variable {key} cannot contain null bytes" + )); + } + let value_bytes = value.len(); + if value_bytes > MAX_TOOL_CONTEXT_VALUE_BYTES { + return Err(format!( + "Agent tool context variable {key} exceeds the {MAX_TOOL_CONTEXT_VALUE_BYTES} byte limit" + )); + } + total_bytes = total_bytes + .checked_add(key.len()) + .and_then(|total| total.checked_add(value_bytes)) + .ok_or_else(|| "Agent tool context size overflowed".to_string())?; + if total_bytes > MAX_TOOL_CONTEXT_TOTAL_BYTES { + return Err(format!( + "Agent tool context exceeds the {MAX_TOOL_CONTEXT_TOTAL_BYTES} byte total limit" + )); + } + } + + Ok(Self { + values, + scrub_from_parent, + ephemeral, + }) + } +} + +fn validate_key(key: &str) -> Result<(), String> { + if key.is_empty() || key.contains(['=', '\0']) { + return Err("Agent tool context variable names are invalid".to_string()); + } + if key.len() > MAX_TOOL_CONTEXT_KEY_BYTES { + return Err(format!( + "Agent tool context variable names must be at most {MAX_TOOL_CONTEXT_KEY_BYTES} bytes" + )); + } + Ok(()) +} + +struct AgentToolContextState { + values: BTreeMap, + scrub_from_parent: BTreeSet, + ephemeral: bool, +} + +#[derive(Clone)] +pub(crate) struct SharedAgentToolContext { + state: Arc>, + revoked: CancellationToken, + launch_gate: Arc>, +} + +impl SharedAgentToolContext { + pub(crate) fn new(spec: AgentToolContextSpec) -> Self { + Self { + state: Arc::new(RwLock::new(AgentToolContextState { + values: spec.values, + scrub_from_parent: spec.scrub_from_parent, + ephemeral: spec.ephemeral, + })), + revoked: CancellationToken::new(), + launch_gate: Arc::new(Mutex::new(())), + } + } + + pub(crate) fn snapshot(&self) -> AgentToolContextSnapshot { + let state = self + .state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + AgentToolContextSnapshot { + values: state.values.clone(), + scrub_from_parent: state.scrub_from_parent.clone(), + ephemeral: state.ephemeral, + revoked: self.revoked.clone(), + launch_gate: Arc::clone(&self.launch_gate), + } + } + + pub(crate) fn revoke(&self) { + // Linearize revocation with command construction and spawn. Once this + // method returns, no snapshot taken before revocation can launch a new + // process with its copied values. + let _launch = self + .launch_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.revoked.cancel(); + let mut state = self + .state + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.values.clear(); + state.ephemeral = false; + // Retain inherited-key scrubbing after revocation. A removed explicit + // credential must never reveal a same-named ambient process value. + } + + pub(crate) fn cancel_run(&self, run: &CancellationToken) { + // A run cancellation and a tool launch share the same fence. Once this + // method returns, a snapshot from this context cannot cross the launch + // boundary for that run. + let _launch = self + .launch_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + run.cancel(); + } + + pub(crate) fn ptr_eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } + + pub(crate) fn is_revoked(&self) -> bool { + self.revoked.is_cancelled() + } +} + +pub(crate) struct AgentToolContextSnapshot { + pub(crate) values: BTreeMap, + pub(crate) scrub_from_parent: BTreeSet, + pub(crate) ephemeral: bool, + pub(crate) revoked: CancellationToken, + launch_gate: Arc>, +} + +impl AgentToolContextSnapshot { + pub(crate) fn begin_process_launch( + &self, + run: &CancellationToken, + ) -> Result, String> { + let guard = self + .launch_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.revoked.is_cancelled() { + Err("Agent tool context was revoked before command launch".to_string()) + } else if run.is_cancelled() { + Err("Agent run was cancelled before command launch".to_string()) + } else { + Ok(guard) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn revocation_clears_values_but_retains_inherited_scrubbing() { + let context = SharedAgentToolContext::new( + AgentToolContextSpec::try_new( + BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]), + BTreeSet::from(["TOKEN".to_string()]), + true, + ) + .unwrap(), + ); + + context.revoke(); + let snapshot = context.snapshot(); + assert!(snapshot.values.is_empty()); + assert_eq!( + snapshot.scrub_from_parent, + BTreeSet::from(["TOKEN".to_string()]) + ); + assert!(!snapshot.ephemeral); + assert!(snapshot.revoked.is_cancelled()); + } + + #[test] + fn validation_is_generic_and_bounded() { + assert!(AgentToolContextSpec::try_new( + BTreeMap::from([("CUSTOM_TOKEN".to_string(), "value".to_string())]), + BTreeSet::new(), + false, + ) + .is_ok()); + assert!(AgentToolContextSpec::try_new( + BTreeMap::from([("BAD=KEY".to_string(), "value".to_string())]), + BTreeSet::new(), + false, + ) + .is_err()); + } + + #[test] + fn run_cancellation_returns_only_after_the_process_launch_fence() { + let context = SharedAgentToolContext::new(AgentToolContextSpec::default()); + let snapshot = context.snapshot(); + let run = CancellationToken::new(); + let launch = snapshot.begin_process_launch(&run).unwrap(); + let cancellation_context = context.clone(); + let cancellation_run = run.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let task = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + cancellation_context.cancel_run(&cancellation_run); + finished_tx.send(()).unwrap(); + }); + + started_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("cancellation thread should start"); + assert!(finished_rx + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err()); + drop(launch); + finished_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("cancellation should finish after launch releases the fence"); + task.join().unwrap(); + + assert!(run.is_cancelled()); + assert!(snapshot.begin_process_launch(&run).is_err()); + } +} diff --git a/frontend/src-tauri/src/agent/web_permission.rs b/frontend/src-tauri/src/agent/web_permission.rs index 6ffe1875e..ce125a96f 100644 --- a/frontend/src-tauri/src/agent/web_permission.rs +++ b/frontend/src-tauri/src/agent/web_permission.rs @@ -499,7 +499,7 @@ mod tests { request_params.get("chat_template_kwargs"), Some(&serde_json::json!({ "enable_thinking": false })) ); - assert!(request_params.get("thinking_effort").is_none()); + assert!(!request_params.contains_key("thinking_effort")); } #[tokio::test] diff --git a/frontend/src-tauri/src/agent_acp.rs b/frontend/src-tauri/src/agent_acp.rs new file mode 100644 index 000000000..0f901ce2d --- /dev/null +++ b/frontend/src-tauri/src/agent_acp.rs @@ -0,0 +1,2424 @@ +use crate::agent::{ + AgentCreateSessionRequest, AgentHostEventPolicy, AgentPermissionDecision, + AgentPermissionRequest, AgentRunCancellation, AgentRunEvent, AgentRunPermissionResponder, + AgentRunTerminal, AgentRuntimeHandle, AgentSendMessageRequest, AgentTimelineItem, + AgentToolContextLease, AgentToolContextSpec, MapleAgentService, + AGENT_TOOL_CONTEXT_INACTIVE_ERROR, +}; +use crate::agent_host::AgentHostLifecycle; +use crate::maple_api::{account_scope, MapleApiAuthState}; +use agent_client_protocol::schema::v1::{ + AgentCapabilities, CancelNotification, ContentBlock, ContentChunk, Implementation, + InitializeRequest, InitializeResponse, McpServer, NewSessionRequest, NewSessionResponse, + PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, SessionId, SessionNotification, + SessionUpdate, StopReason, TextContent, ToolCall, ToolCallContent, ToolCallStatus, ToolKind, +}; +use agent_client_protocol::util::MatchDispatchFrom; +use agent_client_protocol::{ + Agent as AcpAgent, Client, ConnectionTo, Dispatch, HandleDispatchFrom, Handled, + JsonRpcNotification, Lines, Responder, +}; +use futures_util::StreamExt as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; +use tokio_util::codec::{FramedRead, LinesCodec}; +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 MAX_ACP_OUTBOUND_EVENTS_IN_FLIGHT: usize = 256; +const MAX_ACP_OUTBOUND_BYTES_IN_FLIGHT: usize = 4 * 1024 * 1024; +const ACP_OUTBOUND_FRAME_OVERHEAD_BYTES: usize = 256; +const ACP_CONNECTION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const ACP_SYNTHETIC_STOP_DRAIN_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", +]; +const SENSITIVE_BRIDGE_ENV: [&str; 5] = [ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_DISPLAY_NAME", +]; + +#[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, + } + } +} + +fn is_session_update_line(line: &str) -> bool { + serde_json::from_str::(line) + .ok() + .and_then(|message| { + message + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .as_deref() + == Some("session/update") +} + +#[cfg(unix)] +fn tracked_outgoing_lines( + writer: W, + outbound: Arc, +) -> impl futures_util::Sink + Send +where + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + futures_util::sink::unfold( + (writer, outbound), + |(mut writer, outbound), line: String| async move { + use tokio::io::AsyncWriteExt as _; + + let session_update = is_session_update_line(&line); + writer.write_all(line.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + if session_update { + // Credits return only after the real local socket accepted the + // complete notification. A peer that stops reading therefore + // backpressures Maple instead of growing ACP's internal queues. + outbound.acknowledge_session_update(); + } + Ok((writer, outbound)) + }, + ) +} + +#[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 { + // ACP callers own every unresolved interactive decision. Keep the old + // allow_all variant readable for configuration compatibility, but do + // not let it bypass the caller through Maple's Auto policy. + "smart_approve" + } +} + +#[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 { + account_scope: String, + endpoint: PathBuf, + config: Arc>, + stats: Arc, + cancellation: CancellationToken, + task: tauri::async_runtime::JoinHandle<()>, +} + +pub struct AgentAcpState { + running: Mutex>, +} + +impl AgentAcpState { + pub fn new() -> Self { + Self { + running: Mutex::new(None), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[notification(method = "_maple/bridge/hello")] +struct BridgeHelloNotification { + environment: HashMap, +} + +struct AcpConnectionContext { + agent: AgentRuntimeHandle, + config: Arc>, + stats: Arc, + bridge_environment: Mutex>, + sessions: Mutex>, + prompt_states: Mutex>, + background_tasks: Mutex>, + finalization: Mutex<()>, + lifetime: CancellationToken, + closed: AtomicBool, + has_credentials: AtomicBool, + outbound: Arc, +} + +enum AcpPromptState { + Starting { + cancellation: CancellationToken, + }, + Running { + cancellation: CancellationToken, + run_cancellation: Box, + }, +} + +struct AcpOutboundTracker { + event_slots: Arc, + byte_slots: Arc, + pending: std::sync::Mutex>, +} + +struct AcpOutboundReservation { + _event: OwnedSemaphorePermit, + _bytes: OwnedSemaphorePermit, +} + +#[derive(Debug)] +enum AcpOutboundSendError { + UpdateTooLarge, + Cancelled, + Transport(agent_client_protocol::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AcpPermissionResolution { + Continue, + Cancelled, +} + +impl AcpOutboundTracker { + fn new() -> Arc { + Self::with_limits( + MAX_ACP_OUTBOUND_EVENTS_IN_FLIGHT, + MAX_ACP_OUTBOUND_BYTES_IN_FLIGHT, + ) + } + + fn with_limits(event_limit: usize, byte_limit: usize) -> Arc { + Arc::new(Self { + event_slots: Arc::new(Semaphore::new(event_limit)), + byte_slots: Arc::new(Semaphore::new(byte_limit)), + pending: std::sync::Mutex::new(VecDeque::new()), + }) + } + + async fn reserve( + &self, + encoded_bytes: usize, + cancellation: &CancellationToken, + ) -> Result { + let charged_bytes = encoded_bytes.saturating_add(ACP_OUTBOUND_FRAME_OVERHEAD_BYTES); + let Ok(charged_bytes) = u32::try_from(charged_bytes) else { + return Err(AcpOutboundSendError::UpdateTooLarge); + }; + if charged_bytes as usize > MAX_ACP_OUTBOUND_BYTES_IN_FLIGHT { + return Err(AcpOutboundSendError::UpdateTooLarge); + } + + let event = tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(AcpOutboundSendError::Cancelled), + permit = Arc::clone(&self.event_slots).acquire_owned() => { + permit.map_err(|_| AcpOutboundSendError::Cancelled)? + } + }; + let bytes = tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(AcpOutboundSendError::Cancelled), + permit = Arc::clone(&self.byte_slots).acquire_many_owned(charged_bytes) => { + permit.map_err(|_| AcpOutboundSendError::Cancelled)? + } + }; + Ok(AcpOutboundReservation { + _event: event, + _bytes: bytes, + }) + } + + fn enqueue( + &self, + cx: &ConnectionTo, + notification: SessionNotification, + reservation: AcpOutboundReservation, + ) -> Result<(), AcpOutboundSendError> { + // Serialize reservation order with the protocol enqueue. The socket + // writer can then release one exact FIFO credit for each written + // session/update line, even when several ACP sessions stream together. + let mut pending = self + .pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.push_back(reservation); + if let Err(error) = cx.send_notification(notification) { + pending.pop_back(); + return Err(AcpOutboundSendError::Transport(error)); + } + Ok(()) + } + + fn acknowledge_session_update(&self) { + let reservation = self + .pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop_front(); + if reservation.is_none() { + log::warn!("Maple ACP wrote an untracked session/update notification"); + } + drop(reservation); + } +} + +impl AcpConnectionContext { + fn new( + agent: AgentRuntimeHandle, + config: Arc>, + stats: Arc, + ) -> Arc { + Arc::new(Self { + agent, + 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(()), + lifetime: CancellationToken::new(), + closed: AtomicBool::new(false), + has_credentials: AtomicBool::new(false), + outbound: AcpOutboundTracker::new(), + }) + } + + 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(); + let project_root = 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 tool_context = bridge_tool_context_spec(&environment).map_err(internal_acp_error)?; + let mode = config.permission_mode.maple_mode().to_string(); + let created = self + .agent + .create_session_with_tool_context( + Some(AgentCreateSessionRequest { + project_root: Some(project_root.to_string_lossy().into_owned()), + title: Some("Buzz ACP".to_string()), + model: None, + context_limit: None, + mode: Some(mode), + mcp_server_names: None, + }), + Some(tool_context), + // The ACP caller is the only interactive surface for this task. + // Persisted history remains loadable in Maple Desktop, but live + // permission cards must never create a second approval broker. + AgentHostEventPolicy::Suppress, + ) + .await + .map_err(internal_acp_error)?; + let session_id = created.detail.session.id; + let lease = created + .tool_context_lease + .expect("an explicit Agent tool context must return a lease"); + let finalization = self.finalization.lock().await; + if self.closed.load(Ordering::SeqCst) { + drop(finalization); + self.discard_uncommitted_session(&session_id, lease).await; + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection closed while configuring the session")); + } + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&session_id) { + drop(sessions); + drop(finalization); + self.discard_uncommitted_session(&session_id, lease).await; + return Err(agent_client_protocol::Error::internal_error() + .data("The Maple ACP connection duplicated a new session")); + } + sessions.insert(session_id.clone(), lease); + drop(sessions); + self.stats.active_sessions.fetch_add(1, Ordering::SeqCst); + 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, lease: AgentToolContextLease) { + lease.release().await; + let _ = self + .agent + .discard_session_during_cleanup(session_id.to_string()) + .await; + } + + async fn retire_session(&self, session_id: &str) { + let lease = self.sessions.lock().await.remove(session_id); + if let Some(lease) = lease { + self.stats.active_sessions.fetch_sub(1, Ordering::SeqCst); + lease.release().await; + } + } + + async fn begin_prompt( + &self, + request: &PromptRequest, + ) -> Result<(String, CancellationToken), agent_client_protocol::Error> { + 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")); + } + let cancellation = self.lifetime.child_token(); + states.insert( + session_id, + AcpPromptState::Starting { + cancellation: cancellation.clone(), + }, + ); + Ok((prompt, cancellation)) + } + + async fn send_session_update( + &self, + cx: &ConnectionTo, + notification: SessionNotification, + cancellation: &CancellationToken, + ) -> Result<(), AcpOutboundSendError> { + let encoded_bytes = serde_json::to_vec(¬ification) + .map_err(|error| { + AcpOutboundSendError::Transport( + agent_client_protocol::Error::internal_error() + .data(format!("Failed to encode Maple ACP update: {error}")), + ) + })? + .len(); + let reservation = self.outbound.reserve(encoded_bytes, cancellation).await?; + self.outbound.enqueue(cx, notification, reservation) + } + + async fn send_final_agent_message( + &self, + cx: &ConnectionTo, + session_id: SessionId, + message: &str, + cancellation: &CancellationToken, + ) -> Result<(), AcpOutboundSendError> { + self.send_session_update( + cx, + SessionNotification::new( + session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text( + TextContent::new(message.to_string()), + ))), + ), + cancellation, + ) + .await + } + + async fn request_permission_from_caller( + &self, + cx: &ConnectionTo, + session_id: SessionId, + request: AgentPermissionRequest, + item: &AgentTimelineItem, + responder: &AgentRunPermissionResponder, + cancellation: &CancellationToken, + ) -> Result { + let tool_call = acp_permission_tool_call(&request, item); + let permission_request = + RequestPermissionRequest::new(session_id, tool_call.into(), acp_permission_options()); + let encoded_bytes = serde_json::to_vec(&permission_request) + .map_err(|error| { + AcpOutboundSendError::Transport(internal_acp_error(format!( + "Failed to encode Maple ACP permission request: {error}" + ))) + })? + .len(); + // Permission requests use the same global event/byte budget as streamed + // notifications. Hold the reservation until the caller responds so a + // slow client cannot accumulate unbounded JSON-RPC request frames. + let reservation = self.outbound.reserve(encoded_bytes, cancellation).await?; + if cancellation.is_cancelled() { + cancel_maple_permission(responder, &request.request_id).await; + return Ok(AcpPermissionResolution::Cancelled); + } + let sent_request = cx.send_request(permission_request); + let mut response_future = Box::pin(sent_request.block_task()); + let response = tokio::select! { + biased; + _ = cancellation.cancelled() => None, + response = &mut response_future => Some(response), + }; + let Some(response) = response else { + // ACP v1 has no stable request-cancellation primitive. Stop Maple + // immediately, but keep consuming the already-sent JSON-RPC request + // and retain its outbound credits until the client replies or the + // connection closes. Otherwise a cancel-and-never-reply client can + // accumulate unbounded SDK correlation entries outside our limit. + cancel_maple_permission(responder, &request.request_id).await; + retain_cancelled_permission_request(response_future, reservation); + return Ok(AcpPermissionResolution::Cancelled); + }; + drop(reservation); + + let (decision, resolution) = match response { + Ok(response) => match acp_permission_decision(&response.outcome) { + Ok(AgentPermissionDecision::Cancel) => ( + AgentPermissionDecision::Cancel, + AcpPermissionResolution::Cancelled, + ), + Ok(decision) => (decision, AcpPermissionResolution::Continue), + Err(error) => { + cancel_maple_permission(responder, &request.request_id).await; + return Err(AcpOutboundSendError::Transport(internal_acp_error(error))); + } + }, + Err(error) => { + cancel_maple_permission(responder, &request.request_id).await; + return Err(AcpOutboundSendError::Transport(error)); + } + }; + + if let Err(error) = responder.respond(request.request_id, decision).await { + if cancellation.is_cancelled() { + return Ok(AcpPermissionResolution::Cancelled); + } + return Err(AcpOutboundSendError::Transport(internal_acp_error( + format!("Failed to resolve Maple permission request: {error}"), + ))); + } + Ok(resolution) + } + + async fn prompt( + self: &Arc, + cx: &ConnectionTo, + request: PromptRequest, + prompt: String, + prompt_lifetime: CancellationToken, + ) -> Result { + let session_id = request.session_id.0.to_string(); + let config = self.config.read().await.clone(); + let tool_context_access = match self.sessions.lock().await.get(&session_id) { + Some(lease) => lease.access(), + None => { + self.prompt_states.lock().await.remove(&session_id); + return Err(agent_client_protocol::Error::resource_not_found(Some( + session_id.clone(), + )) + .data("ACP session is no longer owned by this connection")); + } + }; + let run = match self + .agent + .send_message_with_tool_context( + 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, + }, + tool_context_access, + prompt_lifetime.clone(), + AgentHostEventPolicy::Suppress, + ) + .await + { + Ok(run) => run, + Err(error) if error == AGENT_TOOL_CONTEXT_INACTIVE_ERROR => { + self.prompt_states.lock().await.remove(&session_id); + self.retire_session(&session_id).await; + return Err(agent_client_protocol::Error::resource_not_found(Some( + session_id.clone(), + )) + .data("The Maple Agent task was removed outside this ACP connection")); + } + Err(error) => { + self.prompt_states.lock().await.remove(&session_id); + return Err(internal_acp_error(error)); + } + }; + let mut events = run.events; + let mut terminal = run.terminal; + let event_overflowed = run.event_overflowed; + let Some(run_cancellation) = run.cancellation else { + prompt_lifetime.cancel(); + self.prompt_states.lock().await.remove(&session_id); + return Err(agent_client_protocol::Error::internal_error() + .data("Maple did not create an ACP cancellation capability for this run")); + }; + let Some(permission_responder) = run.permission_responder else { + prompt_lifetime.cancel(); + let _ = run_cancellation.cancel().await; + self.prompt_states.lock().await.remove(&session_id); + return Err(agent_client_protocol::Error::internal_error() + .data("Maple did not create an ACP permission responder for this run")); + }; + let prompt_registered = { + let mut states = self.prompt_states.lock().await; + match states.get_mut(&session_id) { + Some(state @ AcpPromptState::Starting { .. }) => { + *state = AcpPromptState::Running { + cancellation: prompt_lifetime.clone(), + run_cancellation: Box::new(run_cancellation.clone()), + }; + true + } + _ => false, + } + }; + if !prompt_registered { + let _ = run_cancellation.cancel().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 prompt_lifetime.is_cancelled() { + // A cancellation failure does not make the active Maple run + // disappear. Keep listening so its lifecycle remains tracked. + let _ = run_cancellation.cancel().await; + } + + let mut cancel_after_result = false; + let result = loop { + if event_overflowed.load(Ordering::Acquire) { + cancel_after_result = true; + let _ = run_cancellation.cancel().await; + match self + .send_final_agent_message( + cx, + request.session_id.clone(), + "Maple stopped this turn because its bounded ACP event stream overflowed.", + &self.lifetime, + ) + .await + { + Ok(()) | Err(AcpOutboundSendError::UpdateTooLarge) => { + break Ok(PromptResponse::new(StopReason::EndTurn)); + } + Err(AcpOutboundSendError::Cancelled) => { + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + let event = events.recv().await; + if event_overflowed.load(Ordering::Acquire) { + cancel_after_result = true; + let _ = run_cancellation.cancel().await; + match self + .send_final_agent_message( + cx, + request.session_id.clone(), + "Maple stopped this turn because its bounded ACP event stream overflowed.", + &self.lifetime, + ) + .await + { + Ok(()) | Err(AcpOutboundSendError::UpdateTooLarge) => { + break Ok(PromptResponse::new(StopReason::EndTurn)); + } + Err(AcpOutboundSendError::Cancelled) => { + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + match event { + Some(AgentRunEvent::TimelineItem(item)) => { + if let Some(update) = timeline_update(&item) { + match self + .send_session_update( + cx, + SessionNotification::new(request.session_id.clone(), update), + &prompt_lifetime, + ) + .await + { + Ok(()) => {} + Err(AcpOutboundSendError::UpdateTooLarge) => { + cancel_after_result = true; + let _ = run_cancellation.cancel().await; + match self.send_final_agent_message( + cx, + request.session_id.clone(), + "Maple stopped this turn because one ACP update exceeded the 4 MiB transport limit.", + &self.lifetime, + ) + .await + { + Ok(()) | Err(AcpOutboundSendError::UpdateTooLarge) => { + break Ok(PromptResponse::new(StopReason::EndTurn)); + } + Err(AcpOutboundSendError::Cancelled) => { + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + Err(AcpOutboundSendError::Cancelled) => { + cancel_after_result = true; + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + } + Some(AgentRunEvent::PermissionRequested { + request: permission, + item, + }) => { + match self + .request_permission_from_caller( + cx, + request.session_id.clone(), + permission, + &item, + &permission_responder, + &prompt_lifetime, + ) + .await + { + Ok(AcpPermissionResolution::Continue) => {} + Ok(AcpPermissionResolution::Cancelled) => { + cancel_after_result = true; + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::UpdateTooLarge) => { + cancel_after_result = true; + let _ = run_cancellation.cancel().await; + match self + .send_final_agent_message( + cx, + request.session_id.clone(), + "Maple stopped this turn because one ACP permission request exceeded the 4 MiB transport limit.", + &self.lifetime, + ) + .await + { + Ok(()) | Err(AcpOutboundSendError::UpdateTooLarge) => { + break Ok(PromptResponse::new(StopReason::EndTurn)); + } + Err(AcpOutboundSendError::Cancelled) => { + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + Err(AcpOutboundSendError::Cancelled) => { + cancel_after_result = true; + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + Some(AgentRunEvent::Error(item)) => { + if let Some(message) = event_error_text(&item) { + match self + .send_session_update( + cx, + SessionNotification::new( + request.session_id.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::Text(TextContent::new(message)), + )), + ), + &prompt_lifetime, + ) + .await + { + Ok(()) => {} + Err(AcpOutboundSendError::UpdateTooLarge) => { + cancel_after_result = true; + let _ = run_cancellation.cancel().await; + match self.send_final_agent_message( + cx, + request.session_id.clone(), + "Maple stopped this turn because one ACP update exceeded the 4 MiB transport limit.", + &self.lifetime, + ) + .await + { + Ok(()) | Err(AcpOutboundSendError::UpdateTooLarge) => { + break Ok(PromptResponse::new(StopReason::EndTurn)); + } + Err(AcpOutboundSendError::Cancelled) => { + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + Err(AcpOutboundSendError::Cancelled) => { + cancel_after_result = true; + break Ok(PromptResponse::new(StopReason::Cancelled)); + } + Err(AcpOutboundSendError::Transport(error)) => break Err(error), + } + } + } + Some(AgentRunEvent::Finished(terminal)) => { + break prompt_result_from_terminal(terminal); + } + Some( + AgentRunEvent::SessionUpdated(_) + | AgentRunEvent::Started + | AgentRunEvent::SetupWarning(_) + | AgentRunEvent::HistoryReplaced, + ) => {} + None => { + let current_terminal = *terminal.borrow(); + let fallback = match current_terminal { + Some(terminal) => Some(terminal), + None => match terminal.changed().await { + Ok(()) => *terminal.borrow_and_update(), + Err(_) => *terminal.borrow(), + }, + }; + if let Some(terminal) = fallback { + break prompt_result_from_terminal(terminal); + } + break Err(agent_client_protocol::Error::internal_error() + .data("Maple Agent run ended without a terminal result")); + } + } + }; + let mut deferred_prompt_cleanup = false; + if cancel_after_result { + // Synthetic stream stops settle only after the underlying run has + // drained, or retain a same-session fence while it finishes in the + // background. A completed run makes this cancellation a no-op. + let _ = run_cancellation.cancel().await; + if tokio::time::timeout( + ACP_SYNTHETIC_STOP_DRAIN_TIMEOUT, + wait_for_retained_terminal(&mut terminal), + ) + .await + .is_err() + { + // Do not let Buzz start a replacement turn against the same + // Goose session while cancellation is still draining. The ACP + // response remains bounded, while this retained state and task + // own the terminal barrier asynchronously. + deferred_prompt_cleanup = true; + let context = Arc::clone(self); + let draining_session_id = session_id.clone(); + let mut tasks = self.background_tasks.lock().await; + tasks.spawn(async move { + wait_for_retained_terminal(&mut terminal).await; + if matches!( + context + .prompt_states + .lock() + .await + .remove(&draining_session_id), + Some(AcpPromptState::Running { .. }) + ) { + context.stats.active_runs.fetch_sub(1, Ordering::SeqCst); + } + }); + } + } else if result.is_err() { + let _ = run_cancellation.cancel().await; + } + if !deferred_prompt_cleanup + && 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 (cancellation, run_cancellation) = { + let states = self.prompt_states.lock().await; + match states.get(&session_id) { + Some(AcpPromptState::Starting { cancellation }) => { + (Some(cancellation.clone()), None) + } + Some(AcpPromptState::Running { + cancellation, + run_cancellation, + }) => (Some(cancellation.clone()), Some(run_cancellation.clone())), + None => (None, None), + } + }; + if let Some(cancellation) = cancellation { + // This token reaches core setup before a run ID exists and fences + // the worker start once core setup completes. + cancellation.cancel(); + } + if let Some(run_cancellation) = run_cancellation { + run_cancellation + .cancel() + .await + .map_err(internal_acp_error)?; + } + Ok(()) + } + + async fn cleanup(&self) { + let deadline = tokio::time::Instant::now() + ACP_CONNECTION_CLEANUP_TIMEOUT; + { + // 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.lifetime.cancel(); + 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); + let mut running_cancellations = Vec::new(); + for state in prompt_states.into_values() { + match state { + AcpPromptState::Starting { cancellation } => cancellation.cancel(), + AcpPromptState::Running { + cancellation, + run_cancellation, + } => { + cancellation.cancel(); + running_cancellations.push(run_cancellation); + self.stats.active_runs.fetch_sub(1, Ordering::SeqCst); + } + } + } + let sessions = std::mem::take(&mut *self.sessions.lock().await); + let session_count = sessions.len(); + // Revoke every capability synchronously before awaiting registry cleanup. + // No queued or detached task can launch another credential-bearing tool + // after this barrier returns. + for lease in sessions.values() { + lease.revoke(); + } + self.stats + .active_sessions + .fetch_sub(session_count, Ordering::SeqCst); + let mut tasks = self.background_tasks.lock().await; + for run_cancellation in running_cancellations { + tasks.spawn(async move { + let _ = run_cancellation.cancel().await; + }); + } + for lease in sessions.into_values() { + tasks.spawn(async move { + lease.release().await; + }); + } + 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, prompt_lifetime) = match context.begin_prompt(&request).await { + Ok(prepared) => prepared, + Err(error) => { + responder.respond_with_error(error)?; + return Ok(()); + } + }; + 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, prompt_lifetime) + .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, + lifecycle: tauri::State<'_, AgentHostLifecycle>, + user_id: String, + config: AgentAcpConfig, +) -> Result { + let _guard = lifecycle.lock().await; + app_handle + .state::() + .ensure_accepting_new_work()?; + let config = normalize_config(config)?; + let requested_scope = account_scope(&user_id)?; + let state = app_handle.state::(); + let running = state.running.lock().await; + if let Some(running) = running.as_ref() { + if running.account_scope == requested_scope { + let current = running.config.read().await.clone(); + if running.stats.running.load(Ordering::SeqCst) + && (current.permission_mode != config.permission_mode + || current.allowed_project_roots != config.allowed_project_roots + || current.max_connections != config.max_connections) + { + return Err( + "Stop the ACP service before changing its permission, project-root, or connection policy" + .to_string(), + ); + } + } + } + save_config(&app_handle, &user_id, &config)?; + if let Some(running) = running.as_ref() { + if running.account_scope == requested_scope { + *running.config.write().await = config.clone(); + } + } + Ok(config) +} + +#[tauri::command] +pub async fn agent_acp_start( + app_handle: AppHandle, + lifecycle: tauri::State<'_, AgentHostLifecycle>, + user_id: String, +) -> Result { + let _guard = lifecycle.lock().await; + app_handle + .state::() + .ensure_accepting_new_work()?; + start_service_locked(&app_handle, &user_id).await?; + status(&app_handle, &user_id).await +} + +#[tauri::command] +pub async fn agent_acp_stop( + app_handle: AppHandle, + lifecycle: tauri::State<'_, AgentHostLifecycle>, + user_id: String, +) -> Result { + let _guard = lifecycle.lock().await; + stop_service_locked(&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(crate) async fn shutdown_agent_acp_locked( + app_handle: &AppHandle, + requested_user: Option<&str>, +) -> Result<(), String> { + stop_service_locked(app_handle, requested_user, false).await +} + +#[cfg(unix)] +async fn start_service_locked(app_handle: &AppHandle, user_id: &str) -> Result<(), String> { + let requested_scope = account_scope(user_id) + .map_err(|_| "Cannot start ACP without an authenticated Maple user".to_string())?; + let state = app_handle.state::(); + let stale = { + let mut slot = state.running.lock().await; + match slot.as_ref() { + Some(running) if running.stats.running.load(Ordering::SeqCst) => { + if running.account_scope == requested_scope { + 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)?; + } + let agent = app_handle + .state::() + .handle_for_user(user_id) + .await?; + let maple_api_session = app_handle + .state::() + .session_for(user_id) + .await?; + agent.start(maple_api_session, 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(), + agent, + Arc::clone(&config), + Arc::clone(&stats), + cancellation.clone(), + )); + *state.running.lock().await = Some(RunningAgentAcp { + account_scope: requested_scope, + endpoint, + config, + stats, + cancellation, + task, + }); + Ok(()) +} + +#[cfg(not(unix))] +async fn start_service_locked(_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_locked( + app_handle: &AppHandle, + requested_user: Option<&str>, + persist_disabled: bool, +) -> Result<(), String> { + let state = app_handle.state::(); + let requested_scope = requested_user.map(account_scope).transpose()?; + let running = { + let mut slot = state.running.lock().await; + if let (Some(requested), Some(running)) = (requested_scope.as_deref(), slot.as_ref()) { + if running.account_scope != 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_for_scope(app_handle, &running.account_scope, &config)?; + } + Ok(()) +} + +async fn status(app_handle: &AppHandle, user_id: &str) -> Result { + let state = app_handle.state::(); + let requested_scope = account_scope(user_id)?; + let running = state.running.lock().await; + let harness = harness()?; + if let Some(running) = running.as_ref() { + if running.account_scope != requested_scope { + 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, + agent: AgentRuntimeHandle, + 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 agent = agent.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( + agent, + config, + Arc::clone(&stats), + ); + let (read, write) = stream.into_split(); + let peer_eof = CancellationToken::new(); + let read = BoundedLineReader::new(read, peer_eof.clone()); + let incoming = FramedRead::new( + read, + LinesCodec::new_with_max_length(MAX_ACP_FRAME_BYTES), + ) + .map(|result| { + result.map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + }) + }); + let outgoing = tracked_outgoing_lines( + write, + Arc::clone(&context.outbound), + ); + let serving = AcpAgent + .builder() + .name("maple-acp") + .with_handler(MapleAcpHandler { + context: Arc::clone(&context), + }) + .connect_to(Lines::new(outgoing, incoming)); + 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 { + let scope = account_scope(user_id) + .map_err(|_| "Maple ACP configuration requires an authenticated user".to_string())?; + config_path_for_scope(app_handle, &scope) +} + +fn config_path_for_scope(app_handle: &AppHandle, scope: &str) -> Result { + Ok(acp_accounts_root(app_handle)? + .join(scope) + .join("config.json")) +} + +fn acp_accounts_root(app_handle: &AppHandle) -> Result { + 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")) +} + +fn legacy_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 legacy_scope = digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(acp_accounts_root(app_handle)? + .join(legacy_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 => { + let legacy_path = legacy_config_path(app_handle, user_id)?; + match std::fs::read(legacy_path) { + Ok(bytes) => { + let config = serde_json::from_slice(&bytes) + .map_err(|error| { + format!("Failed to parse Maple ACP configuration: {error}") + }) + .and_then(normalize_config)?; + // Keep the POC file intact so switching back to the original + // branch remains harmless, while future saves use Maple's + // canonical full account scope. + if let Err(error) = save_config(app_handle, user_id, &config) { + log::warn!("Failed to migrate Maple ACP configuration: {error}"); + } + Ok(config) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(AgentAcpConfig::default()) + } + Err(error) => Err(format!("Failed to read Maple ACP configuration: {error}")), + } + } + 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 scope = account_scope(user_id) + .map_err(|_| "Maple ACP configuration requires an authenticated user".to_string())?; + save_config_for_scope(app_handle, &scope, config) +} + +fn save_config_for_scope( + app_handle: &AppHandle, + account_scope: &str, + config: &AgentAcpConfig, +) -> Result<(), String> { + let path = config_path_for_scope(app_handle, account_scope)?; + 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 paths = [ + config_path(app_handle, user_id)?, + legacy_config_path(app_handle, user_id)?, + ]; + for path in paths { + let account_dir = path + .parent() + .ok_or_else(|| "Invalid Maple ACP configuration path".to_string())?; + match std::fs::remove_dir_all(account_dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("Failed to clear Maple ACP configuration: {error}")); + } + } + } + Ok(()) +} + +fn normalize_config(mut config: AgentAcpConfig) -> Result { + // `allow_all` was the exploratory Desktop-owned bypass. Caller-owned ACP + // supersedes it; old files migrate to the guarded policy on their next load. + config.permission_mode = AgentAcpPermissionMode::ReadOnly; + 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 { + let cwd = cwd + .canonicalize() + .map_err(|error| format!("Failed to resolve ACP session cwd: {error}"))?; + if allowed_roots.is_empty() { + return Ok(cwd); + } + for root in allowed_roots { + if let Ok(root) = Path::new(root).canonicalize() { + if cwd.starts_with(root) { + return Ok(cwd); + } + } + } + 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() +} + +pub(crate) fn default_tool_context_spec() -> Result { + AgentToolContextSpec::try_new( + BTreeMap::new(), + SENSITIVE_BRIDGE_ENV + .into_iter() + .map(str::to_string) + .collect(), + false, + ) +} + +fn bridge_tool_context_spec( + environment: &HashMap, +) -> Result { + let values = environment + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + let scrub_from_parent = SENSITIVE_BRIDGE_ENV + .into_iter() + .map(str::to_string) + .collect::>(); + let ephemeral = SENSITIVE_BRIDGE_ENV + .iter() + .any(|key| environment.contains_key(*key)); + AgentToolContextSpec::try_new(values, scrub_from_parent, ephemeral) +} + +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 acp_permission_tool_call( + request: &AgentPermissionRequest, + item: &AgentTimelineItem, +) -> ToolCall { + let title = item + .title + .clone() + .unwrap_or_else(|| format!("Approve {}", request.tool_name)); + let mut tool_call = ToolCall::new(request.request_id.clone(), title) + .kind(acp_tool_kind(&request.tool_name)) + .status(ToolCallStatus::Pending) + .raw_input(serde_json::Value::Object(request.arguments.clone())); + if let Some(prompt) = request.prompt.as_ref().filter(|prompt| !prompt.is_empty()) { + tool_call = tool_call.content(vec![ToolCallContent::from(ContentBlock::Text( + TextContent::new(prompt.clone()), + ))]); + } + tool_call +} + +fn acp_tool_kind(tool_name: &str) -> ToolKind { + match tool_name.rsplit("__").next().unwrap_or(tool_name) { + "shell" | "computer" => ToolKind::Execute, + "text_editor" => ToolKind::Edit, + "search" | "web_search" => ToolKind::Search, + "open_url" => ToolKind::Fetch, + _ => ToolKind::Other, + } +} + +fn acp_permission_options() -> Vec { + vec![ + PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce), + PermissionOption::new( + "reject_once", + "Reject once", + PermissionOptionKind::RejectOnce, + ), + ] +} + +fn acp_permission_decision( + outcome: &RequestPermissionOutcome, +) -> Result { + match outcome { + RequestPermissionOutcome::Cancelled => Ok(AgentPermissionDecision::Cancel), + RequestPermissionOutcome::Selected(selected) + if selected.option_id.0.as_ref() == "allow_once" => + { + Ok(AgentPermissionDecision::AllowOnce) + } + RequestPermissionOutcome::Selected(selected) + if selected.option_id.0.as_ref() == "reject_once" => + { + Ok(AgentPermissionDecision::DenyOnce) + } + RequestPermissionOutcome::Selected(_) => { + Err("ACP client selected an unknown Maple permission option".to_string()) + } + _ => Err("ACP client returned an unsupported Maple permission outcome".to_string()), + } +} + +async fn cancel_maple_permission(responder: &AgentRunPermissionResponder, request_id: &str) { + if let Err(error) = responder + .respond(request_id.to_string(), AgentPermissionDecision::Cancel) + .await + { + log::debug!( + "Maple ACP permission request {request_id} was already resolved while failing closed: {error}" + ); + } +} + +fn retain_cancelled_permission_request(response: F, reservation: AcpOutboundReservation) +where + F: std::future::Future + Send + 'static, + T: Send + 'static, +{ + tokio::spawn(async move { + let _reservation = reservation; + let _ = response.await; + }); +} + +fn timeline_update(item: &AgentTimelineItem) -> Option { + 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(item: &AgentTimelineItem) -> Option { + item.text.clone().map(|message| bounded_error(&message)) +} + +async fn wait_for_retained_terminal( + terminal: &mut tokio::sync::watch::Receiver>, +) { + loop { + if terminal.borrow().is_some() { + return; + } + if terminal.changed().await.is_err() { + return; + } + } +} + +fn prompt_result_from_terminal( + terminal: AgentRunTerminal, +) -> Result { + match terminal { + AgentRunTerminal::Completed => Ok(PromptResponse::new(StopReason::EndTurn)), + AgentRunTerminal::Cancelled => Ok(PromptResponse::new(StopReason::Cancelled)), + // A failed terminal is emitted only after a run was admitted. Goose may + // already have persisted output or executed tools, and Buzz treats a + // JSON-RPC AgentError as pre-mutation/retryable. The preceding error + // update carries the failure text; settle the turn successfully here so + // non-idempotent work is never replayed automatically. + AgentRunTerminal::Failed => Ok(PromptResponse::new(StopReason::EndTurn)), + } +} + +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::*; + use agent_client_protocol::schema::v1::SelectedPermissionOutcome; + + #[test] + fn default_config_is_disabled_and_caller_mediated() { + let config = AgentAcpConfig::default(); + assert!(!config.enabled); + assert_eq!(config.permission_mode, AgentAcpPermissionMode::ReadOnly); + assert_eq!(config.max_connections, 1); + } + + #[test] + fn legacy_allow_all_cannot_bypass_the_acp_caller() { + assert_eq!( + AgentAcpPermissionMode::ReadOnly.maple_mode(), + "smart_approve" + ); + assert_eq!( + AgentAcpPermissionMode::AllowAll.maple_mode(), + "smart_approve" + ); + let migrated = normalize_config(AgentAcpConfig { + permission_mode: AgentAcpPermissionMode::AllowAll, + ..AgentAcpConfig::default() + }) + .unwrap(); + assert_eq!(migrated.permission_mode, AgentAcpPermissionMode::ReadOnly); + } + + #[test] + fn permission_request_exposes_only_one_shot_caller_choices() { + let request = AgentPermissionRequest { + request_id: "request-1".to_string(), + tool_name: "developer__shell".to_string(), + arguments: serde_json::Map::from_iter([( + "command".to_string(), + serde_json::json!("git push"), + )]), + prompt: Some("Push this branch?".to_string()), + }; + let item = AgentTimelineItem { + id: "permission-request-1".to_string(), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some("Push branch".to_string()), + text: request.prompt.clone(), + status: Some("pending".to_string()), + input: Some(serde_json::Value::Object(request.arguments.clone())), + output: None, + created_ms: 1, + merge: "replace".to_string(), + }; + let permission = RequestPermissionRequest::new( + "session-1", + acp_permission_tool_call(&request, &item).into(), + acp_permission_options(), + ); + let encoded = serde_json::to_value(permission).unwrap(); + + assert_eq!(encoded["toolCall"]["toolCallId"], "request-1"); + assert_eq!(encoded["toolCall"]["title"], "Push branch"); + assert_eq!(encoded["toolCall"]["kind"], "execute"); + assert_eq!(encoded["toolCall"]["status"], "pending"); + assert_eq!(encoded["toolCall"]["rawInput"]["command"], "git push"); + assert_eq!( + encoded["options"], + serde_json::json!([ + { "optionId": "allow_once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "reject_once", "name": "Reject once", "kind": "reject_once" } + ]) + ); + } + + #[test] + fn permission_outcomes_map_fail_closed() { + assert_eq!( + acp_permission_decision(&RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new("allow_once") + )), + Ok(AgentPermissionDecision::AllowOnce) + ); + assert_eq!( + acp_permission_decision(&RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new("reject_once") + )), + Ok(AgentPermissionDecision::DenyOnce) + ); + assert_eq!( + acp_permission_decision(&RequestPermissionOutcome::Cancelled), + Ok(AgentPermissionDecision::Cancel) + ); + assert!(acp_permission_decision(&RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new("allow_always") + )) + .is_err()); + } + + #[cfg(unix)] + #[tokio::test] + async fn outbound_tracker_releases_credit_only_after_a_socket_write_acknowledgement() { + use futures_util::SinkExt as _; + use tokio::io::AsyncReadExt as _; + + let tracker = AcpOutboundTracker::with_limits(1, 1024); + let cancellation = CancellationToken::new(); + let first = tracker.reserve(1, &cancellation).await.unwrap(); + tracker + .pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push_back(first); + + let waiting_tracker = Arc::clone(&tracker); + let waiting_cancellation = cancellation.clone(); + let waiting = + tokio::spawn(async move { waiting_tracker.reserve(1, &waiting_cancellation).await }); + tokio::task::yield_now().await; + assert!(!waiting.is_finished()); + + let line = r#"{"jsonrpc":"2.0","method":"session/update","params":{}}"#; + let (writer, mut reader) = tokio::io::duplex(1024); + let mut sink = Box::pin(tracked_outgoing_lines(writer, Arc::clone(&tracker))); + sink.send(line.to_string()).await.unwrap(); + let mut written = vec![0_u8; line.len() + 1]; + reader.read_exact(&mut written).await.unwrap(); + assert_eq!(written, format!("{line}\n").into_bytes()); + + let second = waiting.await.unwrap().unwrap(); + drop(second); + } + + #[tokio::test] + async fn cancelled_permission_retains_credit_until_the_orphan_request_settles() { + let tracker = AcpOutboundTracker::with_limits(1, 1024); + let cancellation = CancellationToken::new(); + let first = tracker.reserve(1, &cancellation).await.unwrap(); + let (settled_tx, settled_rx) = tokio::sync::oneshot::channel::<()>(); + retain_cancelled_permission_request( + async move { + let _ = settled_rx.await; + }, + first, + ); + + assert!(tokio::time::timeout( + std::time::Duration::from_millis(10), + tracker.reserve(1, &cancellation), + ) + .await + .is_err()); + + settled_tx.send(()).unwrap(); + let second = tokio::time::timeout( + std::time::Duration::from_secs(1), + tracker.reserve(1, &cancellation), + ) + .await + .unwrap() + .unwrap(); + drop(second); + } + + #[test] + fn outbound_credit_acknowledges_only_session_updates() { + assert!(is_session_update_line( + r#"{"jsonrpc":"2.0","method":"session/update","params":{}}"# + )); + assert!(!is_session_update_line( + r#"{"jsonrpc":"2.0","id":1,"result":{}}"# + )); + } + + #[test] + fn allowed_project_root_returns_the_canonical_admitted_path() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir(&project).unwrap(); + + let admitted = + ensure_allowed_project_root(&project, &[root.path().to_string_lossy().into_owned()]) + .unwrap(); + + assert_eq!(admitted, project.canonicalize().unwrap()); + } + + #[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" + ); + + let failed = prompt_result_from_terminal(AgentRunTerminal::Failed).unwrap(); + assert_eq!( + serde_json::to_value(failed).unwrap()["stopReason"], + "end_turn" + ); + } +} diff --git a/frontend/src-tauri/src/agent_host.rs b/frontend/src-tauri/src/agent_host.rs new file mode 100644 index 000000000..068bf9422 --- /dev/null +++ b/frontend/src-tauri/src/agent_host.rs @@ -0,0 +1,288 @@ +use crate::agent::{ + AgentPathLayout, AgentRuntimeHandle, AgentRuntimeStatus, AgentStartRequest, + MapleAgentHostResources, MapleAgentService, +}; +use crate::maple_api::MapleApiSession; +use serde::Serialize; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::{Mutex, MutexGuard}; + +/// Serializes operations that span both Agent surfaces. +/// +/// Maple's core runtime has its own internal lifecycle lock, while ACP also +/// owns listener and connection state. Composite host operations such as +/// stop, restart, clear, and app exit must cover both or an ACP start can slip +/// between the two phases and retain a stale runtime handle. +pub(crate) struct AgentHostLifecycle { + gate: Mutex<()>, +} + +/// Result of a runtime mutation that also had to clean up the ACP edge. +/// +/// Runtime success is authoritative even when ACP cleanup reports an error. +/// Keeping both facts lets Desktop resynchronize its runtime state while +/// security-sensitive callers can still fail closed on the ACP warning. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRuntimeLifecycleOutcome { + pub status: AgentRuntimeStatus, + pub acp_shutdown_error: Option, +} + +impl AgentHostLifecycle { + pub(crate) fn new() -> Self { + Self { + gate: Mutex::new(()), + } + } + + pub(crate) async fn lock(&self) -> MutexGuard<'_, ()> { + self.gate.lock().await + } + + pub(crate) async fn start_runtime( + &self, + handle: &AgentRuntimeHandle, + api_session: Arc, + request: Option, + ) -> Result { + let _guard = self.lock().await; + handle.verify_generation().await?; + handle.start(api_session, request).await + } + + pub(crate) async fn stop_runtime( + &self, + app_handle: &AppHandle, + user_id: &str, + handle: &AgentRuntimeHandle, + ) -> Result { + let _guard = self.lock().await; + handle.verify_generation().await?; + let acp_result = + crate::agent_acp::shutdown_agent_acp_locked(app_handle, Some(user_id)).await; + let runtime_result = handle.stop().await; + combine_runtime_lifecycle_results(acp_result, runtime_result, "stop") + } + + pub(crate) async fn restart_runtime( + &self, + app_handle: &AppHandle, + user_id: &str, + handle: &AgentRuntimeHandle, + api_session: Arc, + request: Option, + ) -> Result { + let _guard = self.lock().await; + handle.verify_generation().await?; + let acp_result = + crate::agent_acp::shutdown_agent_acp_locked(app_handle, Some(user_id)).await; + let runtime_result = handle.restart(api_session, request).await; + combine_runtime_lifecycle_results(acp_result, runtime_result, "restart") + } + + pub(crate) async fn clear_user_data( + &self, + app_handle: &AppHandle, + user_id: &str, + handle: &AgentRuntimeHandle, + ) -> Result<(), String> { + let _guard = self.lock().await; + handle.verify_generation().await?; + crate::agent_acp::shutdown_agent_acp_locked(app_handle, Some(user_id)).await?; + handle.clear_data().await?; + crate::agent_acp::clear_agent_acp_config(app_handle, user_id) + } + + pub(crate) async fn clear_user_history( + &self, + app_handle: &AppHandle, + user_id: &str, + handle: &AgentRuntimeHandle, + ) -> Result<(), String> { + let _guard = self.lock().await; + handle.verify_generation().await?; + crate::agent_acp::shutdown_agent_acp_locked(app_handle, Some(user_id)).await?; + handle.clear_history().await + } + + async fn shutdown_services( + &self, + app_handle: &AppHandle, + reopen_on_error: bool, + ) -> Result<(), String> { + let _guard = self.lock().await; + let service = app_handle.state::(); + service.begin_draining(); + let acp_result = crate::agent_acp::shutdown_agent_acp_locked(app_handle, None).await; + let runtime_result = service.shutdown_all().await; + let result = combine_surface_results(acp_result, runtime_result, "stop"); + if reopen_on_error && result.is_err() { + service.reopen_after_failed_shutdown(); + } + result + } + + pub(crate) async fn shutdown_for_exit(&self, app_handle: &AppHandle) -> Result<(), String> { + self.shutdown_services(app_handle, false).await + } + + pub(crate) async fn shutdown_for_update(&self, app_handle: &AppHandle) -> Result<(), String> { + self.shutdown_services(app_handle, true).await + } +} + +fn combine_surface_results( + acp_result: Result<(), String>, + runtime_result: Result, + runtime_action: &str, +) -> Result { + match (acp_result, runtime_result) { + (Ok(()), Ok(runtime)) => Ok(runtime), + (Err(acp), Ok(_)) => Err(acp), + (Ok(()), Err(runtime)) => Err(runtime), + (Err(acp), Err(runtime)) => Err(format!( + "Failed to stop ACP ({acp}); failed to {runtime_action} Agent Mode ({runtime})" + )), + } +} + +fn combine_runtime_lifecycle_results( + acp_result: Result<(), String>, + runtime_result: Result, + runtime_action: &str, +) -> Result { + match (acp_result, runtime_result) { + (acp_result, Ok(status)) => Ok(AgentRuntimeLifecycleOutcome { + status, + acp_shutdown_error: acp_result.err(), + }), + (Ok(()), Err(runtime)) => Err(runtime), + (Err(acp), Err(runtime)) => Err(format!( + "Failed to stop ACP ({acp}); failed to {runtime_action} Agent Mode ({runtime})" + )), + } +} + +/// Compose Maple's transport-neutral runtime with its two edge adapters. +/// +/// Tauri owns Desktop event projection; ACP owns its transient environment +/// policy. Neither adapter reaches through the other to operate the runtime. +pub(crate) fn build_service(app_handle: &AppHandle) -> Result { + let app_config_root = app_handle + .path() + .app_config_dir() + .map_err(|error| format!("Failed to resolve Maple config directory: {error}"))?; + let app_local_data_root = app_handle + .path() + .app_local_data_dir() + .map_err(|error| format!("Failed to resolve Maple local data directory: {error}"))?; + let paths = AgentPathLayout::from_app_roots(app_config_root, app_local_data_root); + let event_sink = crate::agent_tauri::event_sink(app_handle); + let default_tool_context = crate::agent_acp::default_tool_context_spec()?; + Ok(MapleAgentService::new(MapleAgentHostResources::new( + paths, + event_sink, + default_tool_context, + ))) +} + +#[cfg(test)] +mod tests { + use super::{ + combine_runtime_lifecycle_results, combine_surface_results, AgentRuntimeLifecycleOutcome, + }; + use crate::agent::AgentRuntimeStatus; + use std::collections::HashMap; + + fn runtime_status(running: bool) -> AgentRuntimeStatus { + AgentRuntimeStatus { + running, + project_root: None, + model: None, + mode: None, + active_runs: HashMap::new(), + } + } + + #[test] + fn surface_results_return_the_runtime_value_when_both_succeed() { + assert_eq!( + combine_surface_results(Ok(()), Ok("runtime status"), "restart"), + Ok("runtime status") + ); + } + + #[test] + fn surface_results_preserve_a_single_error() { + assert_eq!( + combine_surface_results::<()>(Err("acp error".into()), Ok(()), "stop"), + Err("acp error".into()) + ); + assert_eq!( + combine_surface_results::<()>(Ok(()), Err("runtime error".into()), "stop"), + Err("runtime error".into()) + ); + } + + #[test] + fn surface_results_report_both_errors_and_the_runtime_action() { + assert_eq!( + combine_surface_results::<()>( + Err("acp error".into()), + Err("runtime error".into()), + "restart", + ), + Err( + "Failed to stop ACP (acp error); failed to restart Agent Mode (runtime error)" + .into() + ) + ); + } + + #[test] + fn runtime_lifecycle_reports_acp_partial_failure_with_runtime_status() { + let status = runtime_status(true); + let outcome = combine_runtime_lifecycle_results( + Err("acp error".into()), + Ok(status.clone()), + "restart", + ) + .expect("runtime success should remain observable"); + + assert_eq!(outcome.status.running, status.running); + assert_eq!(outcome.acp_shutdown_error.as_deref(), Some("acp error")); + } + + #[test] + fn runtime_lifecycle_reports_runtime_failures_strictly() { + assert_eq!( + combine_runtime_lifecycle_results(Ok(()), Err("runtime error".into()), "stop"), + Err("runtime error".into()) + ); + assert_eq!( + combine_runtime_lifecycle_results( + Err("acp error".into()), + Err("runtime error".into()), + "restart", + ), + Err( + "Failed to stop ACP (acp error); failed to restart Agent Mode (runtime error)" + .into() + ) + ); + } + + #[test] + fn runtime_lifecycle_success_has_no_cleanup_warning() { + let AgentRuntimeLifecycleOutcome { + status, + acp_shutdown_error, + } = combine_runtime_lifecycle_results(Ok(()), Ok(runtime_status(false)), "stop") + .expect("both surfaces should succeed"); + + assert!(!status.running); + assert!(acp_shutdown_error.is_none()); + } +} diff --git a/frontend/src-tauri/src/agent_tauri.rs b/frontend/src-tauri/src/agent_tauri.rs new file mode 100644 index 000000000..ac9aa880f --- /dev/null +++ b/frontend/src-tauri/src/agent_tauri.rs @@ -0,0 +1,715 @@ +use crate::agent::{ + AgentConfig, AgentCreateSessionRequest, AgentEventSink, AgentMcpServer, + AgentPermissionModeRequest, AgentPermissionResponse, AgentProjectRootRegistration, + AgentProjectSkillsTrustStatus, AgentRunEvent, AgentRunResponse, AgentRunTerminal, + AgentRuntimeHandle, AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, + AgentSessionDetail, AgentSessionMcpServer, AgentSessionSummary, + AgentSetSessionMcpServerRequest, AgentStartRequest, AgentTimelineItem, MapleAgentService, + RecentProjectRoot, +}; +use crate::agent_host::{AgentHostLifecycle, AgentRuntimeLifecycleOutcome}; +use crate::maple_api::MapleApiAuthState; +use serde::Serialize; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; + +const AGENT_EVENT_NAME: &str = "agent-event"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentEventEnvelope { + pub(crate) event_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) message: Option, +} + +pub(crate) fn project_agent_event(event: &AgentServiceEvent) -> AgentEventEnvelope { + let mut envelope = AgentEventEnvelope { + event_type: String::new(), + session_id: None, + run_id: None, + item: None, + status: None, + session: None, + message: None, + }; + match event { + AgentServiceEvent::RuntimeStatus(status) => { + envelope.event_type = "runtimeStatus".to_string(); + envelope.status = Some(status.clone()); + } + AgentServiceEvent::SessionCreated(session) => { + envelope.event_type = "sessionCreated".to_string(); + envelope.session_id = Some(session.id.clone()); + envelope.session = Some(session.clone()); + } + AgentServiceEvent::SessionUpdated { + session_id, + run_id, + session, + } => { + envelope.event_type = "sessionUpdated".to_string(); + envelope.session_id = Some(session_id.clone()); + envelope.run_id.clone_from(run_id); + envelope.session = Some(session.clone()); + } + AgentServiceEvent::TimelineItem { + session_id, + run_id, + item, + } => { + envelope.event_type = "timelineItem".to_string(); + envelope.session_id = Some(session_id.clone()); + envelope.run_id.clone_from(run_id); + envelope.item = Some(item.clone()); + } + AgentServiceEvent::Run { + session_id, + run_id, + event, + } => { + envelope.session_id = Some(session_id.clone()); + envelope.run_id = Some(run_id.clone()); + match event { + AgentRunEvent::SessionUpdated(session) => { + envelope.event_type = "sessionUpdated".to_string(); + envelope.session = Some(session.clone()); + } + AgentRunEvent::Started => envelope.event_type = "runStarted".to_string(), + AgentRunEvent::TimelineItem(item) => { + envelope.event_type = "timelineItem".to_string(); + envelope.item = Some(item.clone()); + } + AgentRunEvent::PermissionRequested { item, .. } => { + // Keep the Desktop wire contract unchanged while the shared + // service exposes a typed permission request to non-Tauri + // callers through the run-local event stream. + envelope.event_type = "timelineItem".to_string(); + envelope.item = Some(item.clone()); + } + AgentRunEvent::SetupWarning(message) => { + envelope.event_type = "error".to_string(); + // Preserve the existing Desktop contract. Setup warnings + // historically carried a run ID but no task ID. + envelope.session_id = None; + envelope.message = Some(message.clone()); + } + AgentRunEvent::HistoryReplaced => { + envelope.event_type = "historyReplaced".to_string(); + } + AgentRunEvent::Error(item) => { + envelope.event_type = "error".to_string(); + envelope.item = Some(item.clone()); + } + AgentRunEvent::Finished(terminal) => { + envelope.event_type = "runFinished".to_string(); + envelope.message = Some( + match terminal { + AgentRunTerminal::Completed => "completed", + AgentRunTerminal::Cancelled => "cancelled", + AgentRunTerminal::Failed => "failed", + } + .to_string(), + ); + } + } + } + } + envelope +} + +struct TauriAgentEventSink { + app_handle: AppHandle, +} + +impl AgentEventSink for TauriAgentEventSink { + fn emit(&self, event: &AgentServiceEvent) { + if let Err(error) = self + .app_handle + .emit(AGENT_EVENT_NAME, project_agent_event(event)) + { + log::warn!("Failed to emit Agent Mode event: {error}"); + } + } +} + +pub(crate) fn event_sink(app_handle: &AppHandle) -> Arc { + Arc::new(TauriAgentEventSink { + app_handle: app_handle.clone(), + }) +} + +async fn handle_for_user( + state: &State<'_, MapleAgentService>, + user_id: &str, +) -> Result { + state.handle_for_user(user_id).await +} + +#[tauri::command] +pub async fn agent_get_runtime_status( + state: State<'_, MapleAgentService>, + user_id: String, +) -> Result { + handle_for_user(&state, &user_id).await?.status().await +} + +#[tauri::command] +pub async fn agent_start_runtime( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + api_auth_state: State<'_, MapleApiAuthState>, + host_lifecycle: State<'_, AgentHostLifecycle>, + user_id: String, + request: Option, +) -> Result { + let _ = app_handle; + let handle = handle_for_user(&state, &user_id).await?; + let api_session = api_auth_state.session_for(&user_id).await?; + host_lifecycle + .start_runtime(&handle, api_session, request) + .await +} + +#[tauri::command] +pub async fn agent_stop_runtime( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + host_lifecycle: State<'_, AgentHostLifecycle>, + user_id: String, +) -> Result { + let handle = handle_for_user(&state, &user_id).await?; + host_lifecycle + .stop_runtime(&app_handle, &user_id, &handle) + .await +} + +#[tauri::command] +pub async fn agent_restart_runtime( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + api_auth_state: State<'_, MapleApiAuthState>, + host_lifecycle: State<'_, AgentHostLifecycle>, + user_id: String, + request: Option, +) -> Result { + let handle = handle_for_user(&state, &user_id).await?; + let api_session = api_auth_state.session_for(&user_id).await?; + host_lifecycle + .restart_runtime(&app_handle, &user_id, &handle, api_session, request) + .await +} + +#[tauri::command] +pub async fn agent_clear_user_data( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + host_lifecycle: State<'_, AgentHostLifecycle>, + user_id: String, +) -> Result<(), String> { + let handle = handle_for_user(&state, &user_id).await?; + host_lifecycle + .clear_user_data(&app_handle, &user_id, &handle) + .await +} + +#[tauri::command] +pub async fn agent_clear_user_history( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + host_lifecycle: State<'_, AgentHostLifecycle>, + user_id: String, +) -> Result<(), String> { + let handle = handle_for_user(&state, &user_id).await?; + host_lifecycle + .clear_user_history(&app_handle, &user_id, &handle) + .await +} + +#[tauri::command] +pub async fn agent_load_config( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id).await?.load_config().await +} + +#[tauri::command] +pub async fn agent_save_config( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + config: AgentConfig, +) -> Result<(), String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .save_config(config) + .await +} + +#[tauri::command] +pub async fn agent_list_mcp_servers( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .list_mcp_servers() + .await +} + +#[tauri::command] +pub async fn agent_save_mcp_servers( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + servers: Vec, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .save_mcp_servers(servers) + .await +} + +#[tauri::command] +pub async fn agent_list_recent_project_roots( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .list_recent_project_roots() + .await +} + +#[tauri::command] +pub async fn agent_save_recent_project_root( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + path: String, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .save_recent_project_root(path) + .await +} + +#[tauri::command] +pub async fn agent_remove_project_root( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + path: String, + fallback_path: Option, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .remove_project_root(path, fallback_path) + .await +} + +#[tauri::command] +pub async fn agent_get_project_skills_trust( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + path: String, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .get_project_skills_trust(path) + .await +} + +#[tauri::command] +pub async fn agent_set_project_skills_trust( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + path: String, + trusted: bool, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .set_project_skills_trust(path, trusted) + .await +} + +#[tauri::command] +pub async fn agent_save_project_root_order( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + paths: Vec, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .save_project_root_order(paths) + .await +} + +#[tauri::command] +pub async fn agent_create_session( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + request: Option, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .create_session(request) + .await +} + +#[tauri::command] +pub async fn agent_list_sessions( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + project_root: Option, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .list_sessions(project_root) + .await +} + +#[tauri::command] +pub async fn agent_load_session( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + session_id: String, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .load_session(session_id) + .await +} + +#[tauri::command] +pub async fn agent_list_session_mcp_servers( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + session_id: String, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .list_session_mcp_servers(session_id) + .await +} + +#[tauri::command] +pub async fn agent_set_session_mcp_server_enabled( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + request: AgentSetSessionMcpServerRequest, +) -> Result, String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .set_session_mcp_server_enabled(request) + .await +} + +#[tauri::command] +pub async fn agent_delete_session( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + session_id: String, +) -> Result<(), String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .delete_session(session_id) + .await +} + +#[tauri::command] +pub async fn agent_send_message( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + request: AgentSendMessageRequest, +) -> Result { + let _ = app_handle; + let run = handle_for_user(&state, &user_id) + .await? + .send_message(request) + .await?; + Ok(AgentRunResponse { run_id: run.run_id }) +} + +#[tauri::command] +pub async fn agent_cancel_run( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + run_id: String, +) -> Result<(), String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .cancel_desktop_run(run_id) + .await +} + +#[tauri::command] +pub async fn agent_set_permission_mode( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + request: AgentPermissionModeRequest, +) -> Result<(), String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .set_permission_mode(request) + .await +} + +#[tauri::command] +pub async fn agent_permission_respond( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + response: AgentPermissionResponse, +) -> Result<(), String> { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .permission_respond(response) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn service_events_project_to_the_stable_desktop_wire_contract() { + let status = AgentRuntimeStatus { + running: true, + project_root: Some("/tmp/project".to_string()), + model: Some("maple-model".to_string()), + mode: Some("smart_approve".to_string()), + active_runs: HashMap::from([("session-1".to_string(), "run-1".to_string())]), + }; + let session = AgentSessionSummary { + id: "session-1".to_string(), + title: "Task".to_string(), + project_root: "/tmp/project".to_string(), + created_ms: 1, + updated_ms: 2, + message_count: 3, + model: Some("maple-model".to_string()), + mode: "smart_approve".to_string(), + }; + let item = AgentTimelineItem { + id: "message-1".to_string(), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some("hello".to_string()), + status: None, + input: None, + output: None, + created_ms: 4, + merge: "append".to_string(), + }; + let events = [ + AgentServiceEvent::RuntimeStatus(status), + AgentServiceEvent::SessionCreated(session.clone()), + AgentServiceEvent::SessionUpdated { + session_id: "session-1".to_string(), + run_id: Some("run-1".to_string()), + session, + }, + AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::Started, + }, + AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::TimelineItem(item.clone()), + }, + AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::Error(item), + }, + AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::HistoryReplaced, + }, + AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::Finished(AgentRunTerminal::Completed), + }, + ]; + let projected = events.iter().map(project_agent_event).collect::>(); + + assert_eq!( + serde_json::to_value(projected).unwrap(), + json!([ + { + "eventType": "runtimeStatus", + "status": { + "running": true, + "projectRoot": "/tmp/project", + "model": "maple-model", + "mode": "smart_approve", + "activeRuns": { "session-1": "run-1" } + } + }, + { + "eventType": "sessionCreated", + "sessionId": "session-1", + "session": { + "id": "session-1", + "title": "Task", + "projectRoot": "/tmp/project", + "createdMs": 1, + "updatedMs": 2, + "messageCount": 3, + "model": "maple-model", + "mode": "smart_approve" + } + }, + { + "eventType": "sessionUpdated", + "sessionId": "session-1", + "runId": "run-1", + "session": { + "id": "session-1", + "title": "Task", + "projectRoot": "/tmp/project", + "createdMs": 1, + "updatedMs": 2, + "messageCount": 3, + "model": "maple-model", + "mode": "smart_approve" + } + }, + { "eventType": "runStarted", "sessionId": "session-1", "runId": "run-1" }, + { + "eventType": "timelineItem", + "sessionId": "session-1", + "runId": "run-1", + "item": { + "id": "message-1", + "itemType": "message", + "role": "assistant", + "text": "hello", + "createdMs": 4, + "merge": "append" + } + }, + { + "eventType": "error", + "sessionId": "session-1", + "runId": "run-1", + "item": { + "id": "message-1", + "itemType": "message", + "role": "assistant", + "text": "hello", + "createdMs": 4, + "merge": "append" + } + }, + { "eventType": "historyReplaced", "sessionId": "session-1", "runId": "run-1" }, + { + "eventType": "runFinished", + "sessionId": "session-1", + "runId": "run-1", + "message": "completed" + } + ]) + ); + + let warning = project_agent_event(&AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::SetupWarning("setup warning".to_string()), + }); + assert_eq!(warning.event_type, "error"); + assert_eq!(warning.session_id, None); + assert_eq!(warning.run_id.as_deref(), Some("run-1")); + assert_eq!(warning.message.as_deref(), Some("setup warning")); + } + + #[test] + fn typed_permission_requests_keep_the_existing_desktop_timeline_shape() { + let item = AgentTimelineItem { + id: "permission-request-1".to_string(), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some("Run shell command".to_string()), + text: Some("Run this command?".to_string()), + status: Some("pending".to_string()), + input: Some(json!({ "command": "git status --short" })), + output: None, + created_ms: 4, + merge: "replace".to_string(), + }; + let projected = project_agent_event(&AgentServiceEvent::Run { + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + event: AgentRunEvent::PermissionRequested { + request: crate::agent::AgentPermissionRequest { + request_id: "request-1".to_string(), + tool_name: "shell".to_string(), + arguments: serde_json::Map::from_iter([( + "command".to_string(), + json!("git status --short"), + )]), + prompt: Some("Run this command?".to_string()), + }, + item: item.clone(), + }, + }); + + assert_eq!(projected.event_type, "timelineItem"); + assert_eq!(projected.session_id.as_deref(), Some("session-1")); + assert_eq!(projected.run_id.as_deref(), Some("run-1")); + assert_eq!(projected.item, Some(item)); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index da26f41a8..e21dd1e56 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,8 +1,14 @@ -use tauri::Emitter; +use tauri::{Emitter, Manager}; use tauri_plugin_deep_link::DeepLinkExt; #[cfg(desktop)] mod agent; +#[cfg(desktop)] +mod agent_acp; +#[cfg(desktop)] +mod agent_host; +#[cfg(desktop)] +mod agent_tauri; #[cfg(any(desktop, target_os = "ios"))] mod legacy_tts_cleanup; #[cfg(desktop)] @@ -16,7 +22,8 @@ mod proxy; #[tauri::command] async fn restart_for_update(app_handle: tauri::AppHandle) -> Result<(), String> { log::info!("User requested restart for update"); - agent::shutdown_agent_runtime(&app_handle).await?; + let lifecycle = app_handle.state::(); + lifecycle.shutdown_for_update(&app_handle).await?; app_handle.restart(); } @@ -51,8 +58,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::shutdown_agent_runtime(&app_handle).await { - log::error!("Failed to stop Agent Mode during app exit: {error}"); + let lifecycle = app_handle.state::(); + if let Err(error) = lifecycle.shutdown_for_exit(&app_handle).await { + log::error!("Failed to stop Agent services during app exit: {error}"); } AGENT_EXIT_CLEANUP_COMPLETE.store(true, Ordering::SeqCst); app_handle.exit(code.unwrap_or_default()); @@ -84,36 +92,42 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) - .manage(agent::AgentRuntimeState::new()) + .manage(agent_acp::AgentAcpState::new()) + .manage(agent_host::AgentHostLifecycle::new()) .manage(maple_api::MapleApiAuthState::new()) .manage(proxy::ProxyState::new()) .invoke_handler(tauri::generate_handler![ - agent::agent_get_runtime_status, - agent::agent_start_runtime, - agent::agent_stop_runtime, - agent::agent_restart_runtime, - agent::agent_load_config, - agent::agent_save_config, - agent::agent_list_mcp_servers, - agent::agent_save_mcp_servers, - agent::agent_list_recent_project_roots, - agent::agent_save_recent_project_root, - agent::agent_remove_project_root, - agent::agent_get_project_skills_trust, - agent::agent_set_project_skills_trust, - agent::agent_save_project_root_order, - agent::agent_create_session, - agent::agent_list_sessions, - agent::agent_load_session, - agent::agent_list_session_mcp_servers, - agent::agent_set_session_mcp_server_enabled, - agent::agent_delete_session, - agent::agent_send_message, - agent::agent_cancel_run, - agent::agent_set_permission_mode, - agent::agent_permission_respond, - agent::agent_clear_user_history, - agent::agent_clear_user_data, + agent_tauri::agent_get_runtime_status, + agent_tauri::agent_start_runtime, + agent_tauri::agent_stop_runtime, + agent_tauri::agent_restart_runtime, + agent_tauri::agent_load_config, + agent_tauri::agent_save_config, + agent_tauri::agent_list_mcp_servers, + agent_tauri::agent_save_mcp_servers, + agent_tauri::agent_list_recent_project_roots, + agent_tauri::agent_save_recent_project_root, + agent_tauri::agent_remove_project_root, + agent_tauri::agent_get_project_skills_trust, + agent_tauri::agent_set_project_skills_trust, + agent_tauri::agent_save_project_root_order, + agent_tauri::agent_create_session, + agent_tauri::agent_list_sessions, + agent_tauri::agent_load_session, + agent_tauri::agent_list_session_mcp_servers, + agent_tauri::agent_set_session_mcp_server_enabled, + agent_tauri::agent_delete_session, + agent_tauri::agent_send_message, + agent_tauri::agent_cancel_run, + agent_tauri::agent_set_permission_mode, + agent_tauri::agent_permission_respond, + agent_tauri::agent_clear_user_history, + agent_tauri::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, @@ -131,6 +145,11 @@ pub fn run() { .setup(|app| { legacy_tts_cleanup::schedule(app.handle()); + let service = agent_host::build_service(app.handle())?; + if !app.manage(service) { + return Err("Maple Agent service was already initialized".into()); + } + // Initialize proxy auto-start { let app_handle_proxy = app.handle().clone(); @@ -370,6 +389,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/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index dcaa0d2ed..6e4b98298 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -1532,8 +1532,11 @@ export function AgentMode({ userId }: { userId: string }) { const requestedMode = selectedModeRef.current; const request = { projectRoot, model: model || DEFAULT_MODEL, mode: requestedMode }; const runStateGeneration = runStateGenerationRef.current; - const status = restart + const restartOutcome = restart ? await agentRuntimeService.restartRuntime(userId, request) + : null; + const status = restartOutcome + ? restartOutcome.status : await agentRuntimeService.startRuntime(userId, request); if ( startRequestGenerationRef.current !== requestGeneration || @@ -1546,6 +1549,11 @@ export function AgentMode({ userId }: { userId: string }) { setModel(status.model || model || DEFAULT_MODEL); applyAuthoritativeMode(normalizeAgentPermissionMode(status.mode || requestedMode)); await refreshSessions(); + if (restartOutcome?.acpShutdownError) { + setError( + `Agent Mode restarted, but ACP cleanup failed: ${restartOutcome.acpShutdownError}` + ); + } return status; }); } catch (startError) { diff --git a/frontend/src/components/settings/AgentConnectionsSettings.tsx b/frontend/src/components/settings/AgentConnectionsSettings.tsx new file mode 100644 index 000000000..0495ab1a0 --- /dev/null +++ b/frontend/src/components/settings/AgentConnectionsSettings.tsx @@ -0,0 +1,759 @@ +import { useCallback, useEffect, useLayoutEffect, 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); + const operationRef = useRef(operation); + const statusRequestGenerationRef = useRef(0); + const isBusy = operation !== null; + useSettingsNavigationLock(isBusy); + + useLayoutEffect(() => { + userIdRef.current = userId; + operationRef.current = operation; + }, [operation, userId]); + + 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." + : running + ? "Stop the ACP service before changing its policy. Maple sends guarded requests to the connected client, which may prompt, deny, or approve automatically." + : "Maple sends guarded requests to the connected client. Buzz currently selects Allow once automatically, so trusted prompts can run unattended."} +

+
+ + + + + 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. + + + +
+ +
+
+
+ + +
+
+ + + + +
+

+ 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. + + + +
+
+ + +
+