diff --git a/crates/goose-server/src/auth.rs b/crates/goose-server/src/auth.rs index d174306e..7503b4da 100644 --- a/crates/goose-server/src/auth.rs +++ b/crates/goose-server/src/auth.rs @@ -6,6 +6,12 @@ use axum::{ }; use subtle::ConstantTimeEq; +fn token_matches(candidate: Option<&str>, expected: &str) -> bool { + candidate + .map(|key| bool::from(key.as_bytes().ct_eq(expected.as_bytes()))) + .unwrap_or(false) +} + pub async fn check_token( State(state): State, request: Request, @@ -24,10 +30,32 @@ pub async fn check_token( .get("X-Secret-Key") .and_then(|value| value.to_str().ok()); - match secret_key { - Some(key) if bool::from(key.as_bytes().ct_eq(state.as_bytes())) => { - Ok(next.run(request).await) - } - _ => Err(StatusCode::UNAUTHORIZED), + if token_matches(secret_key, &state) { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +pub async fn check_acp_token( + State(state): State, + request: Request, + next: Next, +) -> Result { + let header_token = request + .headers() + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()); + + let query_token = request.uri().query().and_then(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key == "token") + .map(|(_, value)| value.into_owned()) + }); + + if token_matches(header_token, &state) || token_matches(query_token.as_deref(), &state) { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) } } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 8e0dbb13..369efe62 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -3,9 +3,14 @@ use crate::state; use anyhow::Result; use axum::middleware; use axum_server::Handle; -use goose_server::auth::check_token; +use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; +use goose::acp::transport::create_acp_router; +use goose::agents::GoosePlatform; +use goose::config::paths::Paths; +use goose_server::auth::{check_acp_token, check_token}; #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] use goose_server::tls::setup_tls; +use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -64,12 +69,28 @@ pub async fn run() -> Result<()> { .allow_methods(Any) .allow_headers(Any); - let app = crate::routes::configure(app_state.clone(), secret_key.clone()) - .layer(middleware::from_fn_with_state( - secret_key.clone(), - check_token, - )) - .layer(cors); + // TODO(acp-migration): When ui/desktop launches `goose serve` directly, + // move any goosed-only ACP setup into the goose serve path before deleting + // this bridge. In particular, verify everything ACP currently gets from + // goosed startup/AppState initialization, including builtin extension + // registration and the desktop platform identity. + let acp_server = Arc::new(AcpServer::new(AcpServerFactoryConfig { + builtins: vec!["developer".to_string()], + data_dir: Paths::data_dir(), + config_dir: Paths::config_dir(), + goose_platform: GoosePlatform::GooseDesktop, + additional_source_roots: Vec::new(), + })); + + let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer( + middleware::from_fn_with_state(secret_key.clone(), check_token), + ); + let acp_router = create_acp_router(acp_server).layer(middleware::from_fn_with_state( + secret_key.clone(), + check_acp_token, + )); + + let app = rest_router.merge(acp_router).layer(cors); let addr = settings.socket_addr(); diff --git a/crates/goose/src/acp/server/extensions.rs b/crates/goose/src/acp/server/extensions.rs index c92dcb86..b45e0407 100644 --- a/crates/goose/src/acp/server/extensions.rs +++ b/crates/goose/src/acp/server/extensions.rs @@ -33,7 +33,12 @@ impl GooseAcpAgent { pub(super) async fn on_get_extensions( &self, ) -> Result { - let extensions = crate::config::extensions::get_all_extensions(); + let extensions = crate::config::extensions::get_all_extensions() + .into_iter() + .filter(|ext| { + !crate::agents::extension_manager::is_hidden_extension(&ext.config.name()) + }) + .collect::>(); let warnings = crate::config::extensions::get_warnings(); let extensions_json = extensions .into_iter() diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index f4c76cb5..ab0ca41d 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -94,10 +94,8 @@ async fn health() -> &'static str { "ok" } -pub fn create_router(server: Arc, secret_key: String) -> Router { - let registry = Arc::new(connection::ConnectionRegistry::new(server)); - - let cors = CorsLayer::new() +fn acp_cors_layer() -> CorsLayer { + CorsLayer::new() .allow_origin(Any) .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ @@ -113,14 +111,26 @@ pub fn create_router(server: Arc, secret_key: String) -> Router { .expose_headers([ HeaderName::from_static("acp-connection-id"), HeaderName::from_static("acp-session-id"), - ]); + ]) +} + +fn create_acp_routes(server: Arc) -> Router { + let registry = Arc::new(connection::ConnectionRegistry::new(server)); Router::new() - .route("/health", get(health)) - .route("/status", get(health)) .route("/acp", post(http::handle_post).with_state(registry.clone())) .route("/acp", get(handle_get).with_state(registry.clone())) .route("/acp", delete(http::handle_delete).with_state(registry)) - .merge(super::mcp_app_proxy::routes(secret_key)) - .layer(cors) +} + +pub fn create_acp_router(server: Arc) -> Router { + create_acp_routes(server).layer(acp_cors_layer()) +} + +pub fn create_router(server: Arc, secret_key: String) -> Router { + create_acp_routes(server) + .route("/health", get(health)) + .route("/status", get(health)) + .merge(super::mcp_app_proxy::routes(secret_key)) + .layer(acp_cors_layer()) } diff --git a/ui/desktop/index.html b/ui/desktop/index.html index 78a85db0..7d407b12 100644 --- a/ui/desktop/index.html +++ b/ui/desktop/index.html @@ -1,8 +1,11 @@ - - + + Goose - +
diff --git a/ui/desktop/package.json b/ui/desktop/package.json index d2de6f70..0380142a 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -9,6 +9,7 @@ }, "main": ".vite/build/main.js", "scripts": { + "postinstall": "pnpm --filter @aaif/goose-sdk run build", "typecheck": "tsc --noEmit", "generate-api": "openapi-ts", "start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start", @@ -46,6 +47,7 @@ }, "dependencies": { "@aaif/goose-sdk": "workspace:*", + "@agentclientprotocol/sdk": "^0.19.0", "@mcp-ui/client": "6.1.0", "@modelcontextprotocol/ext-apps": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", diff --git a/ui/desktop/spike-doc/acp-config-extensions-first-spike.md b/ui/desktop/spike-doc/acp-config-extensions-first-spike.md new file mode 100644 index 00000000..a0cf2e08 --- /dev/null +++ b/ui/desktop/spike-doc/acp-config-extensions-first-spike.md @@ -0,0 +1,380 @@ +# ACP First Spike: Configured Extensions List + +## Purpose + +Use configured extensions as the first `ui/desktop` REST-to-ACP migration slice. + +This spike is intentionally smaller than chat or sessions. It proves that desktop can talk directly to `/acp` through `goosed` during migration, without depending on ACP session ID behavior or chat streaming semantics. + +## Scope + +Migrate the read-only configured extensions list behind a feature flag. + +```text +Current REST: + GET /config/extensions + +Target ACP: + _goose/config/extensions +``` + +Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven. + +## Why This Slice + +- No session ID dependency. +- No chat streaming or prompt lifecycle. +- No tool approval. +- No message-shape conversion. +- Exercises direct renderer WebSocket to `/acp`. +- Exercises ACP initialization and custom `_goose/*` request dispatch. +- Easy to compare REST and ACP responses. + +## Relevant Existing Code + +Desktop REST read path: + +- `ui/desktop/src/components/ConfigContext.tsx` + - imports `getExtensions as apiGetExtensions` + - `refreshExtensions()` calls `apiGetExtensions()` + - initial provider load also calls `apiGetExtensions()` + +Desktop UI consumer: + +- `ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx` + - consumes `extensionsList` from `useConfig()` + - calls `getExtensions(true)` to refresh + +REST backend: + +- `crates/goose-server/src/routes/config_management.rs` + - `GET /config/extensions` + - `POST /config/extensions` + - `DELETE /config/extensions/{name}` + +ACP backend: + +- `crates/goose/src/acp/server/extensions.rs` + - `_goose/config/extensions` + - `_goose/config/extensions/add` + - `_goose/config/extensions/remove` + - `_goose/config/extensions/toggle` + +ACP client reference: + +- `ui/goose2/src/shared/api/createWebSocketStream.ts` +- `ui/goose2/src/shared/api/acpConnection.ts` +- `ui/goose2/src/shared/api/acpApi.ts` + +Treat `ui/goose2` as a reference only. Do not share runtime code with it because `ui/goose2` is expected to move out of this repo. + +## Current Gap Found + +REST `GET /config/extensions` filters hidden extensions: + +```rust +goose::config::get_all_extensions() + .into_iter() + .filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name())) +``` + +ACP `_goose/config/extensions` currently calls `crate::config::extensions::get_all_extensions()` and does not apply the same hidden-extension filter. + +Recommendation: fix ACP to match REST before enabling this feature flag. The goal is to prove the migration path without introducing UI-visible behavior differences. + +## Backend Plan + +## Step-By-Step Review Plan + +Work in small reviewable slices. After each step, stop and review before moving to the next one. + +### Step 1: Inspect router/auth shape + +Goal: confirm the exact `goosed` router composition and ACP transport shape before editing. + +Review points: + +- where REST `X-Secret-Key` middleware is applied +- whether ACP can be mounted as a separate branch +- whether `goose::acp::transport::create_router` causes route collisions +- where ACP token auth should live +- whether Cargo dependencies already allow `goose-server` to call ACP router code + +Expected outcome: a precise patch plan for backend mounting. + +### Step 2: Add ACP-only router helper + +Goal: avoid route collisions by exposing only `/acp` routes for embedding in `goosed`. + +Likely file: + +- `crates/goose/src/acp/transport/mod.rs` + +Reason: existing `create_router(...)` includes `/health`, `/status`, and MCP app proxy routes. `goosed` already owns some of those routes. + +Expected outcome: a helper such as `create_acp_router(...)` or equivalent that only mounts: + +```text +/acp POST +/acp GET +/acp DELETE +``` + +### Step 3: Mount `/acp` in `goosed` + +Goal: serve REST and ACP from the same `goosed` process during migration. + +Likely files: + +- `crates/goose-server/src/commands/agent.rs` +- maybe `crates/goose-server/src/routes/mod.rs` + +Expected shape: + +```text +goosed + REST routes protected by X-Secret-Key + /acp protected by ACP token auth +``` + +### Step 4: Expose ACP URL/token to renderer + +Goal: let renderer connect directly to `/acp` over WebSocket. + +Likely files: + +- `ui/desktop/src/main.ts` +- `ui/desktop/src/preload.ts` +- related Electron type declarations if present + +Expected renderer-facing value: + +```text +ws(s)://127.0.0.1:/acp?token= +``` + +### Step 5: Add minimal desktop ACP client + +Goal: create enough client plumbing to initialize ACP and call one custom method. + +Suggested files: + +```text +ui/desktop/src/acp/createWebSocketStream.ts +ui/desktop/src/acp/acpConnection.ts +ui/desktop/src/acp/acpApi.ts +``` + +Reference, not shared dependency: + +- `ui/goose2/src/shared/api/createWebSocketStream.ts` +- `ui/goose2/src/shared/api/acpConnection.ts` +- `ui/goose2/src/shared/api/acpApi.ts` + +### Step 6: Migrate configured extensions read path behind a flag + +Goal: call `_goose/config/extensions` for read-only extension listing when enabled. + +Primary integration file: + +- `ui/desktop/src/components/ConfigContext.tsx` + +Keep writes on REST in this slice: + +- add extension +- remove extension +- toggle extension +- bundled extension sync/prune + +### Step 7: Validate parity + +Goal: prove ACP and REST return equivalent visible extension data. + +Validation checklist is below. + +## Backend Details + +### Mount `/acp` in `goosed` + +Add the ACP Axum router to `goosed agent`. + +Likely files: + +- `crates/goose-server/src/commands/agent.rs` +- `crates/goose-server/src/routes/mod.rs` + +Use existing ACP pieces: + +```rust +goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig} +goose::acp::transport::create_router +``` + +Keep REST and ACP as separate route branches. + +Watch for route collisions: + +- `/status` +- `/mcp-app-proxy` +- `/mcp-app-guest` + +### Add ACP token auth + +The renderer should connect through direct WebSocket: + +```text +ws(s)://127.0.0.1:/acp?token= +``` + +Do not put `/acp` behind the REST `X-Secret-Key` middleware because browser WebSocket cannot set arbitrary headers. + +Guardrails: + +- Use a random ACP token for the `goosed` process. +- Prefer an ACP-specific token over reusing the raw REST `X-Secret-Key`. +- Never log full `/acp?token=...` URLs. +- Accept ACP token auth only for the local desktop backend. +- Keep REST `X-Secret-Key` for REST routes during migration. + +### Fix `_goose/config/extensions` parity + +Update ACP `_goose/config/extensions` to match REST behavior: + +- filter hidden extensions +- preserve warnings +- preserve the response shape needed by desktop + +The ACP response currently injects a `config_key` field. Verify the desktop shape expected by `ExtensionEntry` and normalize on the client if needed. + +## Desktop Plan + +### Add ACP client files + +Suggested files: + +```text +ui/desktop/src/acp/createWebSocketStream.ts +ui/desktop/src/acp/acpConnection.ts +ui/desktop/src/acp/acpApi.ts +``` + +Base these on `ui/goose2`, but adapt to Electron. + +Differences from `ui/goose2`: + +- URL lookup should use Electron, not Tauri. +- Build ACP URL from the existing `goosed` host. +- Append the ACP token. +- Initialize ACP once and reconnect on close. + +Example URL shape: + +```ts +const baseUrl = await window.electron.getGoosedHostPort(); +const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp?token=' + encodeURIComponent(token); +``` + +If `goosed` is running HTTPS, this becomes `wss://.../acp?...`. + +### Add ACP extensions API wrapper + +Add a wrapper such as: + +```ts +getConfigExtensionsViaAcp(): Promise +``` + +It should call: + +```text +_goose/config/extensions +``` + +Normalize the response to the current desktop `ExtensionResponse` shape: + +```ts +type ExtensionResponse = { + extensions: ExtensionEntry[]; + warnings: string[]; +}; +``` + +### Add a feature flag + +Suggested name: + +```text +acpConfigExtensions +``` + +The exact flag mechanism should follow existing desktop feature-flag conventions if present. If there is no suitable framework, use a local env/config gate for the spike. + +### Route read path through ACP only when enabled + +Primary integration file: + +- `ui/desktop/src/components/ConfigContext.tsx` + +Candidate call sites: + +- `refreshExtensions()` +- initial load effect that fetches extensions after bundled sync + +Keep these mutation paths on REST for the first spike: + +- `addExtension` +- `removeExtension` +- `toggleExtension` +- bundled extension sync/prune + +After REST mutations complete, refresh can use ACP when the flag is enabled. + +### Add fallback behavior + +For the spike, if ACP connection or `_goose/config/extensions` fails, log the error and fall back to REST. + +This fallback is only for the spike. Once a feature area is fully migrated, the matching REST endpoint should be removed. + +## Validation Checklist + +Compare REST and ACP for the same local config: + +- same visible extension count +- same names +- same `enabled` values +- same extension types (`builtin`, `stdio`, `streamable_http`, etc.) +- same warnings +- hidden extensions do not appear +- settings/extensions UI renders correctly +- add/update/delete still work through REST and refresh the list +- failed ACP connection falls back to REST while the spike flag is enabled + +## Removal Rule + +Do not remove `GET /config/extensions` after this first read-only spike if writes still use REST sync logic that depends on the endpoint. + +Remove the REST extension endpoints only after the full extension config surface has migrated: + +```text +GET /config/extensions +POST /config/extensions +DELETE /config/extensions/{name} +``` + +Corresponding ACP methods: + +```text +_goose/config/extensions +_goose/config/extensions/add +_goose/config/extensions/remove +_goose/config/extensions/toggle +``` + +## Done Criteria + +- `goosed` exposes token-authenticated `/acp`. +- `ui/desktop` can open a direct renderer WebSocket to `/acp`. +- ACP initialize succeeds. +- `_goose/config/extensions` returns REST-equivalent visible extension data. +- Feature flag can switch extension list reads between REST and ACP. +- Existing extension settings UI behaves the same under the ACP read path. diff --git a/ui/desktop/spike-doc/acp-migration-spike.md b/ui/desktop/spike-doc/acp-migration-spike.md new file mode 100644 index 00000000..9a02f604 --- /dev/null +++ b/ui/desktop/spike-doc/acp-migration-spike.md @@ -0,0 +1,707 @@ +# Desktop ACP Migration Spike + +## Goal + +`ui/desktop` currently talks to `goosed agent` through REST/OpenAPI. We want to migrate the desktop app to talk to Goose through ACP, while keeping the migration gradual and avoiding a large bundle-size increase. + +The preferred migration direction is: + +- Keep bundling only `goosed` during migration to avoid shipping both large binaries. +- Add ACP serving at `/acp` inside `goosed agent` as a temporary bridge. +- Let `ui/desktop` talk directly to `/acp` for migrated surfaces. +- Keep existing REST routes only for unmigrated areas. +- Once all REST behavior has moved to ACP, stop bundling/running `goosed` and switch desktop to the existing `goose serve` ACP server. + +## Current Architecture + +### Desktop backend + +`goosed` is an Axum HTTP server. + +Relevant files: + +- `crates/goose-server/src/main.rs` +- `crates/goose-server/src/commands/agent.rs` +- `crates/goose-server/src/routes/mod.rs` +- `ui/desktop/src/goosed.ts` + +`ui/desktop/src/goosed.ts` finds and spawns the bundled `goosed` binary: + +```ts +const spawnCommand = goosedPath; +const spawnArgs = ['agent']; +``` + +The renderer configures the generated OpenAPI client against the `goosed` URL in `ui/desktop/src/renderer.tsx`. + +### Desktop REST usage + +`ui/desktop` imports generated API methods from `ui/desktop/src/api`. + +Important chat/session paths today: + +- `ui/desktop/src/sessions.ts` + - `startAgent` creates sessions. +- `ui/desktop/src/hooks/useChatStream.ts` + - `resumeAgent` + - `sessionReply` + - `sessionCancel` + - `getSession` + - `updateFromSession` +- `ui/desktop/src/hooks/useSessionEvents.ts` + - opens `GET /sessions/{id}/events` as SSE. + +The current REST streaming model is Goose-specific: + +- `POST /sessions/{id}/reply` +- `GET /sessions/{id}/events` +- event types such as `Message`, `Finish`, `Error`, `Notification`, `ActiveRequests` +- request routing through `request_id` / `chat_request_id` + +## Existing ACP Implementation + +Goose already has ACP support. + +Relevant files: + +- ACP agent implementation: `crates/goose/src/acp/server.rs` +- ACP transport: `crates/goose/src/acp/transport/mod.rs` +- ACP custom methods: `crates/goose/src/acp/server/custom_dispatch.rs` +- Custom request types: `crates/goose-sdk/src/custom_requests.rs` +- CLI entry points: `crates/goose-cli/src/cli.rs` + +There are two existing ACP modes: + +### `goose acp` + +Runs ACP over stdio: + +```rust +Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await +``` + +This is standard ACP, but in Electron it would require the main process to own stdio and bridge to the renderer. + +### `goose serve` + +Runs ACP over HTTP/SSE/WebSocket: + +```rust +Some(Command::Serve { host, port, builtins }) => handle_serve_command(host, port, builtins).await +``` + +The transport router registers: + +```rust +/health +/status +/acp POST +/acp GET +/acp DELETE +``` + +`GET /acp` upgrades to WebSocket when requested. Otherwise it behaves as SSE. `POST /acp` accepts JSON-RPC messages. + +## Bundle Size Finding + +Local release binary sizes: + +```text +target/release/goose 230M +target/release/goosed 218M +``` + +Bundling both would add roughly: + +```text +goosed + goose = 448M uncompressed +``` + +`ui/desktop/src/bin` currently contains both binaries locally: + +```text +ui/desktop/src/bin/goose 230M +ui/desktop/src/bin/goosed 218M +``` + +This is too large as a long-term plan. + +## Recommended Backend Strategy + +Do not bundle both `goose` and `goosed` for production migration. The temporary bridge is to mount `/acp` into `goosed`; the final backend is `goose serve`. + +Migration backend: + +1. Add ACP serving to `goosed agent`. +2. Keep REST routes available on the same process during migration. +3. Mount `/acp` in the existing `goosed` Axum app. +4. Gradually migrate desktop feature areas from REST to ACP. +5. Remove each relevant REST endpoint once its feature area is migrated. + +Migration shape: + +```text +ui/desktop renderer + -> http(s)://127.0.0.1:/acp + goosed process + existing REST routes temporarily + ACP /acp +``` + +This keeps the backend bundle around current `goosed` size plus a small ACP wiring delta, instead of adding a second 230M binary. + +Final backend: + +```text +ui/desktop renderer + -> ws(s)://127.0.0.1:/acp?token= + goose serve + standard ACP methods + _goose/* custom ACP methods +``` + +In the final state `goosed` is not bundled or spawned by the desktop app. + +Before deleting the `goosed` bridge, verify that the final `goose serve` path has the same +desktop-specific ACP behavior that the bridge depended on during migration. In particular: + +- config and data directories match the desktop app's expected Goose paths +- builtin extension setup matches what desktop needs +- ACP initialization uses the correct desktop platform identity +- any state initialized today by `goosed` startup is either no longer needed or is initialized by + the final desktop `goose serve` launch path + +## Migration Bridge vs Final Server + +Mounting `/acp` in `goosed` should be treated as a bridge, not the destination. + +```text +During migration: + ui/desktop + -> REST for unmigrated features + -> /acp for migrated features + + bundled backend: + goosed + REST routes + temporary /acp route +``` + +```text +After migration: + ui/desktop + -> /acp only + + bundled backend: + goose serve + standard ACP + _goose/* custom ACP methods +``` + +The migration rule is: + +```text +When a feature moves to /acp: + remove its corresponding REST endpoint from goosed +``` + +The final cutover from `goosed agent` to `goose serve` is blocked until no desktop runtime feature depends on REST/OpenAPI. + +Expected effort: + +- Mounting `/acp` in `goosed`: relatively easy because both servers are Axum and the ACP router already exists. +- Removing `goosed` later: medium effort, because every REST-only desktop capability must first be moved into standard ACP or `_goose/*` custom ACP methods. + +## Alternative Considered: Move REST Into `goose serve` + +It is possible to make `goose serve` also mount `goose-server` REST routes. That would make `goose-cli` depend on `goose-server`. + +Tradeoffs: + +- Simpler single `goose` binary packaging. +- But the general-purpose CLI binary gets desktop REST/OpenAPI/tunnel/gateway/server dependencies. +- Likely increases `goose` binary size. +- Blurs the CLI and desktop backend boundary. + +The cleaner migration bridge is the reverse: add ACP to `goosed` temporarily. The final target remains `goose serve`, not `goosed`. + +## Streaming Differences + +Adding `/acp` to `goosed` does not make the current desktop streaming code work unchanged. + +### Current REST streaming + +The current desktop chat stream expects: + +- `GET /sessions/{id}/events` +- Goose-specific `MessageEvent` objects +- `ActiveRequests` +- `request_id` / `chat_request_id` +- `Message`, `Finish`, `Error`, `Notification` + +### ACP streaming + +ACP streaming is protocol-level: + +- Client sends JSON-RPC `session/prompt`. +- Server emits `session/update` notifications. +- Updates include: + - agent message chunks + - user message chunks + - tool calls + - tool call updates + - usage updates + - session info updates + - config option updates + +Tool approval also changes: + +- REST uses `/action-required/tool-confirmation`. +- ACP sends `RequestPermissionRequest`. +- The client must respond on the ACP connection. + +## Recommended Client Strategy + +Use WebSocket ACP directly from the renderer. + +Preferred shape: + +```text +renderer -> wss://127.0.0.1:/acp +send initialize +send session/new +send session/load +send session/prompt +receive session/update notifications continuously +respond to permission requests +``` + +WebSocket is preferable to HTTP POST + SSE because it gives one bidirectional connection for requests, responses, notifications, and permission responses. Do not add an Electron-main IPC transport layer for normal ACP chat traffic. + +## Existing Reference: `ui/goose2` + +`ui/goose2` already has a client pattern that can be reused or adapted. + +Relevant files: + +- `ui/goose2/src/shared/api/createWebSocketStream.ts` +- `ui/goose2/src/shared/api/acpConnection.ts` +- `ui/goose2/src/shared/api/acpApi.ts` +- `ui/goose2/src-tauri/src/services/acp/goose_serve.rs` + +`ui/goose2`: + +- gets a `/acp` WebSocket URL from Tauri +- creates a WebSocket stream +- creates a `GooseClient` +- initializes ACP with client capabilities +- routes `sessionUpdate` notifications through a handler +- exposes APIs such as: + - `listSessions` + - `newSession` + - `loadSession` + - `prompt` + - `cancelSession` + - `setProvider` + - `setModel` + - `_goose/*` custom methods + +`ui/desktop` can use the same pattern, replacing Tauri URL lookup with Electron URL lookup. +Because `ui/goose2` is expected to move out of this repo in the future, desktop should not share runtime code with it. Treat `ui/goose2` as a reference implementation and copy/adapt the small ACP client pieces into `ui/desktop`. + +Example URL derivation: + +```ts +const baseUrl = await window.electron.getGoosedHostPort(); +const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp'; +``` + +If `goosed` is running HTTPS, this becomes `wss://.../acp`. + +## ACP Auth Decision + +Current `goosed` REST uses `X-Secret-Key`. + +Browser WebSocket does not support arbitrary request headers. If `/acp` is mounted behind the same auth middleware, direct renderer WebSocket may fail. + +Chosen direction: `/acp` should have ACP-compatible token auth. + +During migration: + +```text +REST routes: + X-Secret-Key header + +ACP route: + ws(s)://127.0.0.1:/acp?token= +``` + +After REST is removed: + +```text +ACP only: + ws(s)://127.0.0.1:/acp?token= +``` + +This preserves a security boundary for the long-term desktop API while still allowing direct renderer WebSocket connections. + +Guardrails: + +- Use a random ACP token for the `goosed` process. +- Prefer an ACP-specific token over reusing the raw REST `X-Secret-Key`. +- Never log full `/acp?token=...` URLs. +- Keep accepting `X-Secret-Key` only for REST during migration. +- Accept ACP token auth only for the local desktop backend. +- Keep REST and ACP as separate route branches so auth policy and endpoint removal stay clear. + +Alternatives considered: + +1. Mount `/acp` outside `X-Secret-Key` auth, relying on localhost binding. +2. Allow auth through a query parameter for `/acp`, for example `/acp?token=...`. +3. Open the WebSocket from Electron main, where headers are easier, and bridge to renderer via IPC. +4. Use HTTP/SSE transport where headers are possible, though this is less ergonomic for ACP permission/request-response flow. + +Option 2 is the preferred approach. Option 1 matches current `ui/goose2` behavior, but it is weaker as the final desktop backend shape. + +## Migration Routing + +Keep REST and ACP side-by-side only for feature areas that have not moved yet. + +Example shape: + +```ts +const backend = { + sessions: flags.acpSessions ? acpSessions : restSessions, + chat: flags.acpChat ? acpChat : restChat, + providers: flags.acpProviders ? acpProviders : restProviders, +}; +``` + +Rules: + +- `goosed` remains default for unmigrated surfaces. +- ACP is opt-in per feature area. +- New functionality should prefer ACP unless blocked. +- Once a feature area is migrated to `/acp`, remove the corresponding REST endpoint from `goosed` rather than keeping a permanent fallback. +- Any missing behavior discovered during migration should be added to ACP before removing the REST endpoint. +- Final milestone is no runtime dependency on generated REST APIs. + +## Migration Order + +### 1. Mount `/acp` in `goosed` + +Add ACP Axum router to the `goosed agent` app. + +Likely places: + +- `crates/goose-server/src/commands/agent.rs` +- `crates/goose-server/src/routes/mod.rs` + +Use: + +```rust +goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig} +goose::acp::transport::create_router +``` + +Need to decide: + +- route merge order +- whether ACP MCP app proxy should be included once or deduplicated against existing goosed MCP app proxy routes + +Auth model: use token-authenticated `/acp` for direct renderer WebSocket, separate from REST `X-Secret-Key`. + +### 2. Add desktop ACP client + +Add something like: + +```text +ui/desktop/src/acp/createWebSocketStream.ts +ui/desktop/src/acp/acpConnection.ts +ui/desktop/src/acp/acpApi.ts +``` + +This can be based on `ui/goose2`. + +### 3. Migrate session list/create/load + +Map: + +```text +REST /agent/start -> ACP session/new +REST /agent/resume -> ACP session/load +REST /sessions -> ACP session/list +REST /sessions/{id} -> ACP session/load plus replay/session metadata +``` + +This proves: + +- session IDs +- history replay +- session metadata +- current model/provider/mode state + +### 4. Migrate chat streaming + +Map: + +```text +REST /sessions/{id}/reply +REST /sessions/{id}/events + -> ACP session/prompt + session/update notifications +``` + +Prefer moving the chat state toward ACP-native events and data structures rather than preserving the old goosed `MessageEvent` model. A temporary adapter into existing desktop `Message` and `TokenState` shapes is acceptable only as a short bridge if it substantially lowers rollout risk. + +### 5. Migrate tool approval and tool display + +Map: + +```text +REST /action-required/tool-confirmation + -> ACP RequestPermissionRequest response +``` + +Tool display should move toward ACP-native `tool_call` / `tool_call_update` state. Any old desktop message-shape adapter should be treated as temporary migration glue. + +### 6. Migrate provider/model/mode + +Use ACP session config options: + +```text +setSessionConfigOption({ configId: "provider" }) +setSessionConfigOption({ configId: "model" }) +setSessionConfigOption({ configId: "mode" }) +``` + +Use existing Goose custom methods for provider inventory and setup: + +```text +_goose/providers/list +_goose/providers/config/read +_goose/providers/config/save +_goose/providers/config/status +_goose/providers/custom/* +_goose/providers/catalog/* +``` + +### 7. Migrate extensions/tools/resources + +Existing custom ACP methods cover: + +```text +_goose/extensions/add +_goose/extensions/remove +_goose/config/extensions +_goose/config/extensions/add +_goose/config/extensions/remove +_goose/config/extensions/toggle +_goose/session/extensions +_goose/tools +_goose/tool/call +_goose/resource/read +_goose/working_dir/update +``` + +### 8. Migrate settings and secondary surfaces + +Existing ACP custom methods cover many settings/product surfaces: + +```text +_goose/preferences/* +_goose/defaults/* +_goose/onboarding/import/* +_goose/sources/* +_goose/dictation/* +``` + +## First Spike Plan: Configured Extensions List + +Before the ACP session-list PR lands, use configured extensions as the first migration slice. This avoids session ID semantics and chat streaming complexity while still proving the core `/acp` path. + +### Scope + +Migrate the read-only configured extensions list behind a feature flag. + +```text +Current REST: + GET /config/extensions + +Target ACP: + _goose/config/extensions +``` + +Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven. + +### Why This Is A Good First Slice + +- No session ID dependency. +- No chat streaming or prompt lifecycle. +- No tool approval. +- No message-shape conversion. +- Exercises direct renderer WebSocket to `/acp`. +- Exercises ACP initialization and custom `_goose/*` request dispatch. +- Easy to compare REST and ACP responses. + +### Current Gap Found + +REST `GET /config/extensions` filters hidden extensions: + +```rust +goose::config::get_all_extensions() + .into_iter() + .filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name())) +``` + +ACP `_goose/config/extensions` currently calls `crate::config::extensions::get_all_extensions()` and does not apply the same hidden-extension filter. + +For this spike, either: + +1. Fix ACP to match REST before enabling the feature flag, or +2. Allow the mismatch only in local spike mode and track it as the first ACP gap. + +Recommendation: fix ACP to match REST. The goal is to prove migration, not introduce UI-visible behavior differences. + +### Backend Steps + +1. Mount `/acp` in `goosed agent`. + - Add or merge the ACP Axum router into `crates/goose-server`. + - Keep REST and ACP as separate route branches. + - Avoid collisions with existing `/status`, `/mcp-app-proxy`, and `/mcp-app-guest` routes. + +2. Add ACP token auth for `/acp`. + - Direct renderer WebSocket should connect with: + ```text + ws(s)://127.0.0.1:/acp?token= + ``` + - Do not put `/acp` behind REST `X-Secret-Key` middleware. + - Do not log full token-bearing URLs. + +3. Fix `_goose/config/extensions` parity. + - Apply the same hidden-extension filtering as REST. + - Preserve `warnings`. + - Preserve enough shape for existing desktop extension rendering. + +### Desktop Client Steps + +1. Add ACP client files under `ui/desktop/src/acp/`. + Suggested files: + ```text + ui/desktop/src/acp/createWebSocketStream.ts + ui/desktop/src/acp/acpConnection.ts + ui/desktop/src/acp/acpApi.ts + ``` + +2. Base these on `ui/goose2`, but copy/adapt rather than share. + - Replace Tauri URL lookup with Electron lookup. + - Build ACP URL from the existing goosed host. + - Append the ACP token. + - Initialize ACP once and reconnect on close. + +3. Add an ACP extensions API wrapper: + ```ts + getConfigExtensionsViaAcp(): Promise + ``` + It should call: + ```text + _goose/config/extensions + ``` + and normalize the response to the existing desktop `ExtensionResponse` shape. + +4. Add a feature flag. + Suggested name: + ```text + acpConfigExtensions + ``` + +5. Route only the read path through ACP when the flag is enabled. + Primary integration point: + - `ui/desktop/src/components/ConfigContext.tsx` + + Keep mutation paths on REST for this first spike: + - `addExtension` + - `removeExtension` + - `toggleExtension` + - bundled extension sync/prune + +### Validation + +Compare REST and ACP for the same local config: + +- same visible extension count +- same names +- same `enabled` values +- same extension types (`builtin`, `stdio`, `streamable_http`, etc.) +- same warnings +- hidden extensions do not appear +- settings/extensions UI renders correctly +- add/update/delete still work through REST and refresh the list +- failed ACP connection falls back to REST while the spike flag is enabled + +### Removal Rule + +Do not remove `GET /config/extensions` after this first read-only spike if writes still use REST sync logic that depends on the endpoint. + +Remove the REST extension endpoints only after the full extension config surface has migrated: + +```text +GET /config/extensions +POST /config/extensions +DELETE /config/extensions/{name} +``` + +Corresponding ACP methods: + +```text +_goose/config/extensions +_goose/config/extensions/add +_goose/config/extensions/remove +_goose/config/extensions/toggle +``` + +## Known Gaps To Investigate + +Likely REST-only or partially covered areas: + +- recipe encode/decode/scan/schedule/create-from-session +- schedules +- local inference model management/downloads +- tunnel/gateway +- diagnostics/system info +- telemetry +- session sharing +- app export/import/list app flows +- MCP UI proxy details +- current `ActiveRequests` reattach semantics + +During the transition these can remain on REST fallback. The final target is to expose each required capability through Goose custom ACP methods under `_goose/...` unless it maps cleanly to standard ACP. + +## Recommended End State + +Short term: + +```text +goosed exposes REST + temporary /acp +desktop uses REST by default, ACP by feature flag +``` + +Migration: + +```text +desktop moves one feature area at a time to ACP +missing backend behavior is added as standard ACP use or _goose custom methods +matching goosed REST endpoints are removed as each feature migrates +``` + +End state: + +```text +desktop talks to /acp directly +goose serve is the single bundled desktop backend +goosed is no longer bundled or spawned +REST/OpenAPI is removed from desktop runtime behavior +``` + +## Open Decisions + +No major architecture decisions remain from this spike. Implementation details still need validation around route merge order, MCP app proxy deduplication, and exact token plumbing. diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts new file mode 100644 index 00000000..9cbdff89 --- /dev/null +++ b/ui/desktop/src/acp/acpConnection.ts @@ -0,0 +1,93 @@ +import { + DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + GooseClient, + type Client, + type GooseInitializeRequest, +} from '@aaif/goose-sdk'; +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'; +import packageJson from '../../package.json'; +import { createWebSocketStream } from './createWebSocketStream'; + +let clientPromise: Promise | null = null; +let resolvedClient: GooseClient | null = null; + +function createClientCallbacks(): () => Client { + return () => ({ + requestPermission: async () => { + return { + outcome: { + outcome: 'cancelled', + }, + }; + }, + sessionUpdate: async () => {}, + }); +} + +function monitorConnection(client: GooseClient): void { + client.closed + .then(() => { + resolvedClient = null; + clientPromise = null; + }) + .catch(() => { + resolvedClient = null; + clientPromise = null; + }); +} + +async function initializeConnection(): Promise { + const wsUrl = await window.electron.getAcpUrl(); + if (!wsUrl) { + throw new Error('ACP URL is not available'); + } + + const stream = createWebSocketStream(wsUrl); + const client = new GooseClient(createClientCallbacks(), stream); + + await client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + _meta: { + goose: { + mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + }, + }, + }, + clientInfo: { + name: packageJson.name, + version: packageJson.version, + }, + } satisfies GooseInitializeRequest); + + monitorConnection(client); + return client; +} + +export async function getAcpClient(): Promise { + if (resolvedClient) { + return resolvedClient; + } + + if (!clientPromise) { + clientPromise = initializeConnection() + .then((client) => { + resolvedClient = client; + return client; + }) + .catch((error) => { + clientPromise = null; + throw error; + }); + } + + return clientPromise; +} + +export function getAcpClientSync(): GooseClient | null { + return resolvedClient; +} + +export function isAcpClientReady(): boolean { + return resolvedClient !== null; +} diff --git a/ui/desktop/src/acp/createWebSocketStream.ts b/ui/desktop/src/acp/createWebSocketStream.ts new file mode 100644 index 00000000..61b21c55 --- /dev/null +++ b/ui/desktop/src/acp/createWebSocketStream.ts @@ -0,0 +1,77 @@ +import type { Stream } from '@aaif/goose-sdk'; + +export function createWebSocketStream(wsUrl: string): Stream { + const ws = new window.WebSocket(wsUrl); + + const incoming: unknown[] = []; + const waiters: Array<() => void> = []; + let closed = false; + + function pushMessage(message: unknown): void { + incoming.push(message); + waiters.shift()?.(); + } + + function waitForMessage(): Promise { + if (incoming.length > 0 || closed) { + return Promise.resolve(); + } + return new Promise((resolve) => waiters.push(resolve)); + } + + const openPromise = new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve(), { once: true }); + ws.addEventListener('error', () => reject(new Error('ACP WebSocket connection failed')), { + once: true, + }); + }); + + ws.addEventListener('message', (event) => { + if (typeof event.data !== 'string') { + return; + } + try { + pushMessage(JSON.parse(event.data)); + } catch { + // Ignore malformed messages from the transport. + } + }); + + const closeWaiters = () => { + closed = true; + for (const waiter of waiters) { + waiter(); + } + waiters.length = 0; + }; + + ws.addEventListener('close', closeWaiters); + ws.addEventListener('error', closeWaiters); + + const readable = new window.ReadableStream({ + async pull(controller) { + await waitForMessage(); + while (incoming.length > 0) { + controller.enqueue(incoming.shift()); + } + if (closed && incoming.length === 0) { + controller.close(); + } + }, + }); + + const writable = new window.WritableStream({ + async write(message) { + await openPromise; + ws.send(JSON.stringify(message)); + }, + close() { + ws.close(); + }, + abort() { + ws.close(); + }, + }); + + return { readable, writable } as Stream; +} diff --git a/ui/desktop/src/acp/extensions.ts b/ui/desktop/src/acp/extensions.ts new file mode 100644 index 00000000..69d8a80b --- /dev/null +++ b/ui/desktop/src/acp/extensions.ts @@ -0,0 +1,11 @@ +import type { ExtensionResponse, ExtensionEntry } from '../api'; +import { getAcpClient } from './acpConnection'; + +export async function getConfiguredExtensions(): Promise { + const client = await getAcpClient(); + const response = await client.goose.GooseConfigExtensions({}); + return { + extensions: response.extensions as ExtensionEntry[], + warnings: response.warnings, + }; +} diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 434cad8c..018f2f87 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -4,17 +4,16 @@ import { readConfig, removeConfig, upsertConfig, - getExtensions as apiGetExtensions, addExtension as apiAddExtension, removeExtension as apiRemoveExtension, providers, } from '../api'; +import { getConfiguredExtensions } from '../acp/extensions'; import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions'; import type { ConfigResponse, UpsertConfigQuery, ConfigKeyQuery, - ExtensionResponse, ProviderDetails, ExtensionQuery, ExtensionConfig, @@ -48,14 +47,6 @@ interface ConfigProviderProps { children: React.ReactNode; } -export class MalformedConfigError extends Error { - constructor() { - super('Check contents of ~/.config/goose/config.yaml'); - this.name = 'MalformedConfigError'; - Object.setPrototypeOf(this, MalformedConfigError.prototype); - } -} - const ConfigContext = createContext(undefined); export const ConfigProvider: React.FC = ({ children }) => { @@ -114,22 +105,11 @@ export const ConfigProvider: React.FC = ({ children }) => { ); const refreshExtensions = useCallback(async () => { - const result = await apiGetExtensions(); - - if (result.response.status === 422) { - throw new MalformedConfigError(); - } - - if (result.error && !result.data) { - console.error(result.error); - return extensionsList; - } - - const extensionResponse: ExtensionResponse = result.data!; - setExtensionsList(extensionResponse.extensions); - setExtensionWarnings(extensionResponse.warnings || []); - return extensionResponse.extensions; - }, [extensionsList]); + const { extensions, warnings } = await getConfiguredExtensions(); + setExtensionsList(extensions); + setExtensionWarnings(warnings || []); + return extensions; + }, []); const addExtension = useCallback( async (name: string, config: ExtensionConfig, enabled: boolean) => { @@ -212,8 +192,8 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load extensions try { - const extensionsResponse = await apiGetExtensions(); - let extensions = extensionsResponse.data?.extensions || []; + const extensionsResponse = await getConfiguredExtensions(); + let extensions = extensionsResponse.extensions; // Always sync bundled extensions from bundled-extensions.json // This ensures: @@ -235,11 +215,11 @@ export const ConfigProvider: React.FC = ({ children }) => { extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync); await syncBundledExtensions(extensions, addExtensionForSync); // Reload extensions after sync - const refreshedResponse = await apiGetExtensions(); - extensions = refreshedResponse.data?.extensions || []; + const refreshedResponse = await getConfiguredExtensions(); + extensions = refreshedResponse.extensions; setExtensionsList(extensions); - setExtensionWarnings(extensionsResponse.data?.warnings || []); + setExtensionWarnings(extensionsResponse.warnings || []); } catch (error) { console.error('Failed to load extensions:', error); } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 343b5e1c..3470fc92 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -723,6 +723,14 @@ const getServerSecret = (settings: Settings): string => { return GENERATED_SECRET; }; +const buildAcpWebSocketUrl = (baseUrl: string, token: string): string => { + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, '')}/acp`; + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('token', token); + return url.toString(); +}; + let appConfig = { GOOSE_DEFAULT_PROVIDER: defaultProvider, GOOSE_DEFAULT_MODEL: defaultModel, @@ -1624,6 +1632,19 @@ ipcMain.handle('get-goosed-host-port', async (event) => { return client.getConfig().baseUrl || null; }); +ipcMain.handle('get-acp-url', async (event) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id; + if (!windowId) { + return null; + } + const client = goosedClients.get(windowId); + const baseUrl = client?.getConfig().baseUrl; + if (!baseUrl) { + return null; + } + return buildAcpWebSocketUrl(baseUrl, getServerSecret(getSettings())); +}); + // Handle menu bar icon visibility ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => { updateSettings((s) => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 777cff7b..c0a81ea9 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -142,6 +142,7 @@ type ElectronAPI = { setSetting: (key: K, value: Settings[K]) => Promise; getSecretKey: () => Promise; getGoosedHostPort: () => Promise; + getAcpUrl: () => Promise; setWakelock: (enable: boolean) => Promise; getWakelockState: () => Promise; setSpellcheck: (enable: boolean) => Promise; @@ -265,6 +266,7 @@ const electronAPI: ElectronAPI = { }, getSecretKey: () => ipcRenderer.invoke('get-secret-key'), getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'), + getAcpUrl: () => ipcRenderer.invoke('get-acp-url'), setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable), getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'), setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable), diff --git a/ui/desktop/src/utils/__tests__/csp.test.ts b/ui/desktop/src/utils/__tests__/csp.test.ts index a117845b..1ea6c427 100644 --- a/ui/desktop/src/utils/__tests__/csp.test.ts +++ b/ui/desktop/src/utils/__tests__/csp.test.ts @@ -7,6 +7,7 @@ describe('buildConnectSrc', () => { const result = buildConnectSrc(undefined); expect(result).toContain("'self'"); expect(result).toContain('http://127.0.0.1:*'); + expect(result).toContain('wss://127.0.0.1:*'); }); it('includes external backend origin when enabled', () => { @@ -17,6 +18,18 @@ describe('buildConnectSrc', () => { }; const result = buildConnectSrc(config); expect(result).toContain('http://dev.company.net:12604'); + expect(result).toContain('ws://dev.company.net:12604'); + }); + + it('includes external secure WebSocket origin for HTTPS backends', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'https://secure.company.net:12604', + secret: 'test', + }; + const result = buildConnectSrc(config); + expect(result).toContain('https://secure.company.net:12604'); + expect(result).toContain('wss://secure.company.net:12604'); }); it('does not include external origin when disabled', () => { diff --git a/ui/desktop/src/utils/csp.ts b/ui/desktop/src/utils/csp.ts index 69144dae..a2e78ab3 100644 --- a/ui/desktop/src/utils/csp.ts +++ b/ui/desktop/src/utils/csp.ts @@ -4,8 +4,12 @@ const DEFAULT_CONNECT_SOURCES = [ "'self'", 'http://127.0.0.1:*', 'https://127.0.0.1:*', + 'ws://127.0.0.1:*', + 'wss://127.0.0.1:*', 'http://localhost:*', 'https://localhost:*', + 'ws://localhost:*', + 'wss://localhost:*', 'https://api.github.com', 'https://github.com', 'https://objects.githubusercontent.com', @@ -18,6 +22,8 @@ export function buildConnectSrc(externalGoosed?: ExternalGoosedConfig): string { try { const externalUrl = new URL(externalGoosed.url); sources.push(externalUrl.origin); + externalUrl.protocol = externalUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + sources.push(externalUrl.origin); } catch { console.warn('Invalid external goosed URL in settings, skipping CSP entry'); } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index ca34af65..53f13784 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@aaif/goose-sdk': specifier: workspace:* version: link:../sdk + '@agentclientprotocol/sdk': + specifier: ^0.19.0 + version: 0.19.0(zod@3.25.76) '@mcp-ui/client': specifier: 6.1.0 version: 6.1.0(@preact/signals-core@1.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)