mount acp in the goosed server to migrate using acp protocols iteratively (#9097)
This commit is contained in:
@@ -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<String>,
|
||||
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<String>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@ impl GooseAcpAgent {
|
||||
pub(super) async fn on_get_extensions(
|
||||
&self,
|
||||
) -> Result<GetExtensionsResponse, agent_client_protocol::Error> {
|
||||
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::<Vec<_>>();
|
||||
let warnings = crate::config::extensions::get_warnings();
|
||||
let extensions_json = extensions
|
||||
.into_iter()
|
||||
|
||||
@@ -94,10 +94,8 @@ async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
pub fn create_router(server: Arc<AcpServer>, 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<AcpServer>, secret_key: String) -> Router {
|
||||
.expose_headers([
|
||||
HeaderName::from_static("acp-connection-id"),
|
||||
HeaderName::from_static("acp-session-id"),
|
||||
]);
|
||||
])
|
||||
}
|
||||
|
||||
fn create_acp_routes(server: Arc<AcpServer>) -> 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<AcpServer>) -> Router {
|
||||
create_acp_routes(server).layer(acp_cors_layer())
|
||||
}
|
||||
|
||||
pub fn create_router(server: Arc<AcpServer>, 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())
|
||||
}
|
||||
|
||||
+18
-8
@@ -1,8 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://127.0.0.1:* https:; object-src 'none'; font-src 'self' data: https:; media-src 'self' mediastream:; form-action 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self'; frame-src 'self' https: http:;"/>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://127.0.0.1:* https: ws: wss:; object-src 'none'; font-src 'self' data: https:; media-src 'self' mediastream:; form-action 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self'; frame-src 'self' https: http:;"
|
||||
/>
|
||||
<title>Goose</title>
|
||||
<script>
|
||||
// Initialize theme before any content loads
|
||||
@@ -13,8 +16,12 @@
|
||||
const useSystemTheme = localStorage.getItem('use_system_theme') === 'true';
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const isDark = useSystemTheme ? systemPrefersDark : (savedTheme ? savedTheme === 'dark' : systemPrefersDark);
|
||||
|
||||
const isDark = useSystemTheme
|
||||
? systemPrefersDark
|
||||
: savedTheme
|
||||
? savedTheme === 'dark'
|
||||
: systemPrefersDark;
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
@@ -33,7 +40,10 @@
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize theme from localStorage, using system preference:', error);
|
||||
console.warn(
|
||||
'Failed to initialize theme from localStorage, using system preference:',
|
||||
error
|
||||
);
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (systemPrefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
@@ -47,16 +57,16 @@
|
||||
|
||||
// Run immediately
|
||||
initializeTheme();
|
||||
|
||||
|
||||
// Retry after DOM is ready if initial attempt failed
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setTimeout(initializeTheme, 50);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link href="./src/styles/main.css" rel="stylesheet"/>
|
||||
<link href="./src/styles/main.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<GooseClient> | 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<GooseClient> {
|
||||
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<GooseClient> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (incoming.length > 0 || closed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
}
|
||||
|
||||
const openPromise = new Promise<void>((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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ExtensionResponse, ExtensionEntry } from '../api';
|
||||
import { getAcpClient } from './acpConnection';
|
||||
|
||||
export async function getConfiguredExtensions(): Promise<ExtensionResponse> {
|
||||
const client = await getAcpClient();
|
||||
const response = await client.goose.GooseConfigExtensions({});
|
||||
return {
|
||||
extensions: response.extensions as ExtensionEntry[],
|
||||
warnings: response.warnings,
|
||||
};
|
||||
}
|
||||
@@ -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<ConfigContextType | undefined>(undefined);
|
||||
|
||||
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
@@ -114,22 +105,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -142,6 +142,7 @@ type ElectronAPI = {
|
||||
setSetting: <K extends SettingKey>(key: K, value: Settings[K]) => Promise<void>;
|
||||
getSecretKey: () => Promise<string>;
|
||||
getGoosedHostPort: () => Promise<string | null>;
|
||||
getAcpUrl: () => Promise<string | null>;
|
||||
setWakelock: (enable: boolean) => Promise<boolean>;
|
||||
getWakelockState: () => Promise<boolean>;
|
||||
setSpellcheck: (enable: boolean) => Promise<boolean>;
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user