mount acp in the goosed server to migrate using acp protocols iteratively (#9097)

This commit is contained in:
Lifei Zhou
2026-05-18 09:43:30 +10:00
committed by GitHub
parent d04c882388
commit 1fafd74413
17 changed files with 1430 additions and 61 deletions
@@ -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:<port>/acp?token=<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:<port>/acp?token=<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<ExtensionResponse>
```
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.
+707
View File
@@ -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:<port>/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:<port>/acp?token=<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:<port>/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:<port>/acp?token=<acp-token>
```
After REST is removed:
```text
ACP only:
ws(s)://127.0.0.1:<port>/acp?token=<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:<port>/acp?token=<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<ExtensionResponse>
```
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.