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;
|
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(
|
pub async fn check_token(
|
||||||
State(state): State<String>,
|
State(state): State<String>,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -24,10 +30,32 @@ pub async fn check_token(
|
|||||||
.get("X-Secret-Key")
|
.get("X-Secret-Key")
|
||||||
.and_then(|value| value.to_str().ok());
|
.and_then(|value| value.to_str().ok());
|
||||||
|
|
||||||
match secret_key {
|
if token_matches(secret_key, &state) {
|
||||||
Some(key) if bool::from(key.as_bytes().ct_eq(state.as_bytes())) => {
|
Ok(next.run(request).await)
|
||||||
Ok(next.run(request).await)
|
} else {
|
||||||
}
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
_ => 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 anyhow::Result;
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
use axum_server::Handle;
|
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"))]
|
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
|
||||||
use goose_server::tls::setup_tls;
|
use goose_server::tls::setup_tls;
|
||||||
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -64,12 +69,28 @@ pub async fn run() -> Result<()> {
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
let app = crate::routes::configure(app_state.clone(), secret_key.clone())
|
// TODO(acp-migration): When ui/desktop launches `goose serve` directly,
|
||||||
.layer(middleware::from_fn_with_state(
|
// move any goosed-only ACP setup into the goose serve path before deleting
|
||||||
secret_key.clone(),
|
// this bridge. In particular, verify everything ACP currently gets from
|
||||||
check_token,
|
// goosed startup/AppState initialization, including builtin extension
|
||||||
))
|
// registration and the desktop platform identity.
|
||||||
.layer(cors);
|
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();
|
let addr = settings.socket_addr();
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ impl GooseAcpAgent {
|
|||||||
pub(super) async fn on_get_extensions(
|
pub(super) async fn on_get_extensions(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<GetExtensionsResponse, agent_client_protocol::Error> {
|
) -> 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 warnings = crate::config::extensions::get_warnings();
|
||||||
let extensions_json = extensions
|
let extensions_json = extensions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -94,10 +94,8 @@ async fn health() -> &'static str {
|
|||||||
"ok"
|
"ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_router(server: Arc<AcpServer>, secret_key: String) -> Router {
|
fn acp_cors_layer() -> CorsLayer {
|
||||||
let registry = Arc::new(connection::ConnectionRegistry::new(server));
|
CorsLayer::new()
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
|
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
@@ -113,14 +111,26 @@ pub fn create_router(server: Arc<AcpServer>, secret_key: String) -> Router {
|
|||||||
.expose_headers([
|
.expose_headers([
|
||||||
HeaderName::from_static("acp-connection-id"),
|
HeaderName::from_static("acp-connection-id"),
|
||||||
HeaderName::from_static("acp-session-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()
|
Router::new()
|
||||||
.route("/health", get(health))
|
|
||||||
.route("/status", get(health))
|
|
||||||
.route("/acp", post(http::handle_post).with_state(registry.clone()))
|
.route("/acp", post(http::handle_post).with_state(registry.clone()))
|
||||||
.route("/acp", get(handle_get).with_state(registry.clone()))
|
.route("/acp", get(handle_get).with_state(registry.clone()))
|
||||||
.route("/acp", delete(http::handle_delete).with_state(registry))
|
.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>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<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
|
||||||
|
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>
|
<title>Goose</title>
|
||||||
<script>
|
<script>
|
||||||
// Initialize theme before any content loads
|
// Initialize theme before any content loads
|
||||||
@@ -13,8 +16,12 @@
|
|||||||
const useSystemTheme = localStorage.getItem('use_system_theme') === 'true';
|
const useSystemTheme = localStorage.getItem('use_system_theme') === 'true';
|
||||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const isDark = useSystemTheme ? systemPrefersDark : (savedTheme ? savedTheme === 'dark' : systemPrefersDark);
|
const isDark = useSystemTheme
|
||||||
|
? systemPrefersDark
|
||||||
|
: savedTheme
|
||||||
|
? savedTheme === 'dark'
|
||||||
|
: systemPrefersDark;
|
||||||
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
document.documentElement.style.colorScheme = 'dark';
|
document.documentElement.style.colorScheme = 'dark';
|
||||||
@@ -33,7 +40,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
if (systemPrefersDark) {
|
if (systemPrefersDark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
@@ -47,16 +57,16 @@
|
|||||||
|
|
||||||
// Run immediately
|
// Run immediately
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
|
|
||||||
// Retry after DOM is ready if initial attempt failed
|
// Retry after DOM is ready if initial attempt failed
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
setTimeout(initializeTheme, 50);
|
setTimeout(initializeTheme, 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link href="./src/styles/main.css" rel="stylesheet"/>
|
<link href="./src/styles/main.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "pnpm --filter @aaif/goose-sdk run build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"generate-api": "openapi-ts",
|
"generate-api": "openapi-ts",
|
||||||
"start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
|
"start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aaif/goose-sdk": "workspace:*",
|
"@aaif/goose-sdk": "workspace:*",
|
||||||
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@mcp-ui/client": "6.1.0",
|
"@mcp-ui/client": "6.1.0",
|
||||||
"@modelcontextprotocol/ext-apps": "^1.1.1",
|
"@modelcontextprotocol/ext-apps": "^1.1.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@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,
|
readConfig,
|
||||||
removeConfig,
|
removeConfig,
|
||||||
upsertConfig,
|
upsertConfig,
|
||||||
getExtensions as apiGetExtensions,
|
|
||||||
addExtension as apiAddExtension,
|
addExtension as apiAddExtension,
|
||||||
removeExtension as apiRemoveExtension,
|
removeExtension as apiRemoveExtension,
|
||||||
providers,
|
providers,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
import { getConfiguredExtensions } from '../acp/extensions';
|
||||||
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
|
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
|
||||||
import type {
|
import type {
|
||||||
ConfigResponse,
|
ConfigResponse,
|
||||||
UpsertConfigQuery,
|
UpsertConfigQuery,
|
||||||
ConfigKeyQuery,
|
ConfigKeyQuery,
|
||||||
ExtensionResponse,
|
|
||||||
ProviderDetails,
|
ProviderDetails,
|
||||||
ExtensionQuery,
|
ExtensionQuery,
|
||||||
ExtensionConfig,
|
ExtensionConfig,
|
||||||
@@ -48,14 +47,6 @@ interface ConfigProviderProps {
|
|||||||
children: React.ReactNode;
|
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);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||||
@@ -114,22 +105,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const refreshExtensions = useCallback(async () => {
|
const refreshExtensions = useCallback(async () => {
|
||||||
const result = await apiGetExtensions();
|
const { extensions, warnings } = await getConfiguredExtensions();
|
||||||
|
setExtensionsList(extensions);
|
||||||
if (result.response.status === 422) {
|
setExtensionWarnings(warnings || []);
|
||||||
throw new MalformedConfigError();
|
return extensions;
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
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 addExtension = useCallback(
|
const addExtension = useCallback(
|
||||||
async (name: string, config: ExtensionConfig, enabled: boolean) => {
|
async (name: string, config: ExtensionConfig, enabled: boolean) => {
|
||||||
@@ -212,8 +192,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
// Load extensions
|
// Load extensions
|
||||||
try {
|
try {
|
||||||
const extensionsResponse = await apiGetExtensions();
|
const extensionsResponse = await getConfiguredExtensions();
|
||||||
let extensions = extensionsResponse.data?.extensions || [];
|
let extensions = extensionsResponse.extensions;
|
||||||
|
|
||||||
// Always sync bundled extensions from bundled-extensions.json
|
// Always sync bundled extensions from bundled-extensions.json
|
||||||
// This ensures:
|
// This ensures:
|
||||||
@@ -235,11 +215,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
|||||||
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
|
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
|
||||||
await syncBundledExtensions(extensions, addExtensionForSync);
|
await syncBundledExtensions(extensions, addExtensionForSync);
|
||||||
// Reload extensions after sync
|
// Reload extensions after sync
|
||||||
const refreshedResponse = await apiGetExtensions();
|
const refreshedResponse = await getConfiguredExtensions();
|
||||||
extensions = refreshedResponse.data?.extensions || [];
|
extensions = refreshedResponse.extensions;
|
||||||
|
|
||||||
setExtensionsList(extensions);
|
setExtensionsList(extensions);
|
||||||
setExtensionWarnings(extensionsResponse.data?.warnings || []);
|
setExtensionWarnings(extensionsResponse.warnings || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load extensions:', error);
|
console.error('Failed to load extensions:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,6 +723,14 @@ const getServerSecret = (settings: Settings): string => {
|
|||||||
return GENERATED_SECRET;
|
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 = {
|
let appConfig = {
|
||||||
GOOSE_DEFAULT_PROVIDER: defaultProvider,
|
GOOSE_DEFAULT_PROVIDER: defaultProvider,
|
||||||
GOOSE_DEFAULT_MODEL: defaultModel,
|
GOOSE_DEFAULT_MODEL: defaultModel,
|
||||||
@@ -1624,6 +1632,19 @@ ipcMain.handle('get-goosed-host-port', async (event) => {
|
|||||||
return client.getConfig().baseUrl || null;
|
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
|
// Handle menu bar icon visibility
|
||||||
ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => {
|
ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => {
|
||||||
updateSettings((s) => {
|
updateSettings((s) => {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ type ElectronAPI = {
|
|||||||
setSetting: <K extends SettingKey>(key: K, value: Settings[K]) => Promise<void>;
|
setSetting: <K extends SettingKey>(key: K, value: Settings[K]) => Promise<void>;
|
||||||
getSecretKey: () => Promise<string>;
|
getSecretKey: () => Promise<string>;
|
||||||
getGoosedHostPort: () => Promise<string | null>;
|
getGoosedHostPort: () => Promise<string | null>;
|
||||||
|
getAcpUrl: () => Promise<string | null>;
|
||||||
setWakelock: (enable: boolean) => Promise<boolean>;
|
setWakelock: (enable: boolean) => Promise<boolean>;
|
||||||
getWakelockState: () => Promise<boolean>;
|
getWakelockState: () => Promise<boolean>;
|
||||||
setSpellcheck: (enable: boolean) => Promise<boolean>;
|
setSpellcheck: (enable: boolean) => Promise<boolean>;
|
||||||
@@ -265,6 +266,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
|
getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
|
||||||
getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
|
getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
|
||||||
|
getAcpUrl: () => ipcRenderer.invoke('get-acp-url'),
|
||||||
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
|
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
|
||||||
getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'),
|
getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'),
|
||||||
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
|
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ describe('buildConnectSrc', () => {
|
|||||||
const result = buildConnectSrc(undefined);
|
const result = buildConnectSrc(undefined);
|
||||||
expect(result).toContain("'self'");
|
expect(result).toContain("'self'");
|
||||||
expect(result).toContain('http://127.0.0.1:*');
|
expect(result).toContain('http://127.0.0.1:*');
|
||||||
|
expect(result).toContain('wss://127.0.0.1:*');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes external backend origin when enabled', () => {
|
it('includes external backend origin when enabled', () => {
|
||||||
@@ -17,6 +18,18 @@ describe('buildConnectSrc', () => {
|
|||||||
};
|
};
|
||||||
const result = buildConnectSrc(config);
|
const result = buildConnectSrc(config);
|
||||||
expect(result).toContain('http://dev.company.net:12604');
|
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', () => {
|
it('does not include external origin when disabled', () => {
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ const DEFAULT_CONNECT_SOURCES = [
|
|||||||
"'self'",
|
"'self'",
|
||||||
'http://127.0.0.1:*',
|
'http://127.0.0.1:*',
|
||||||
'https://127.0.0.1:*',
|
'https://127.0.0.1:*',
|
||||||
|
'ws://127.0.0.1:*',
|
||||||
|
'wss://127.0.0.1:*',
|
||||||
'http://localhost:*',
|
'http://localhost:*',
|
||||||
'https://localhost:*',
|
'https://localhost:*',
|
||||||
|
'ws://localhost:*',
|
||||||
|
'wss://localhost:*',
|
||||||
'https://api.github.com',
|
'https://api.github.com',
|
||||||
'https://github.com',
|
'https://github.com',
|
||||||
'https://objects.githubusercontent.com',
|
'https://objects.githubusercontent.com',
|
||||||
@@ -18,6 +22,8 @@ export function buildConnectSrc(externalGoosed?: ExternalGoosedConfig): string {
|
|||||||
try {
|
try {
|
||||||
const externalUrl = new URL(externalGoosed.url);
|
const externalUrl = new URL(externalGoosed.url);
|
||||||
sources.push(externalUrl.origin);
|
sources.push(externalUrl.origin);
|
||||||
|
externalUrl.protocol = externalUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
sources.push(externalUrl.origin);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('Invalid external goosed URL in settings, skipping CSP entry');
|
console.warn('Invalid external goosed URL in settings, skipping CSP entry');
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -21,6 +21,9 @@ importers:
|
|||||||
'@aaif/goose-sdk':
|
'@aaif/goose-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
|
'@agentclientprotocol/sdk':
|
||||||
|
specifier: ^0.19.0
|
||||||
|
version: 0.19.0(zod@3.25.76)
|
||||||
'@mcp-ui/client':
|
'@mcp-ui/client':
|
||||||
specifier: 6.1.0
|
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)
|
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