feat(acp): support GOOSE_SERVER__SECRET_KEY at goose serve acp endpoint (#9726)
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
This commit is contained in:
Generated
+2
-1
@@ -4633,6 +4633,7 @@ dependencies = [
|
||||
"smithy-transport-reqwest",
|
||||
"sqlx",
|
||||
"strum 0.28.0",
|
||||
"subtle",
|
||||
"symphonia",
|
||||
"sys-info",
|
||||
"tempfile",
|
||||
@@ -4644,6 +4645,7 @@ dependencies = [
|
||||
"tokio-cron-scheduler",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -4847,7 +4849,6 @@ dependencies = [
|
||||
"serde_path_to_error",
|
||||
"serde_yaml",
|
||||
"socket2",
|
||||
"subtle",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -1326,7 +1326,7 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
|
||||
use goose::config::paths::Paths;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
let builtins = if builtins.is_empty() {
|
||||
vec!["developer".to_string()]
|
||||
@@ -1353,12 +1353,18 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
|
||||
goose_platform: GoosePlatform::GooseCli,
|
||||
additional_source_roots,
|
||||
}));
|
||||
let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
|
||||
let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
|
||||
.ok()
|
||||
.map(|secret| secret.trim().to_string())
|
||||
.filter(|secret| !secret.is_empty())
|
||||
.unwrap_or_else(generate_serve_secret_key);
|
||||
let router = create_router(server, secret_key);
|
||||
.filter(|secret| !secret.is_empty());
|
||||
let require_token = env_secret.is_some();
|
||||
if !require_token {
|
||||
warn!(
|
||||
"{GOOSE_SERVER_SECRET_KEY_ENV} is not set; the ACP endpoint will accept unauthenticated connections"
|
||||
);
|
||||
}
|
||||
let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key);
|
||||
let router = create_router(server, secret_key, require_token);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
||||
info!("Starting ACP server on {}", addr);
|
||||
|
||||
@@ -81,7 +81,6 @@ tokio-tungstenite = { version = "0.29", default-features = false, features = ["c
|
||||
url = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { version = "0.4.3", default-features = false, features = ["std"] }
|
||||
subtle = { version = "2.5", default-features = false, features = ["std"] }
|
||||
socket2 = { version = "0.6", default-features = false }
|
||||
fs2 = { workspace = true }
|
||||
rustls = { workspace = true, optional = true }
|
||||
|
||||
@@ -4,13 +4,8 @@ use axum::{
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
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 use goose::acp::transport::auth::check_acp_token;
|
||||
use goose::acp::transport::auth::token_matches;
|
||||
|
||||
pub async fn check_token(
|
||||
State(state): State<String>,
|
||||
@@ -36,26 +31,3 @@ pub async fn check_token(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ icu_calendar = { version = "=2.1.1", default-features = false }
|
||||
icu_locale = { version = "=2.1.1", default-features = false }
|
||||
llama-cpp-sys-2 = { workspace = true, optional = true }
|
||||
image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
|
||||
subtle = { version = "2.5", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { workspace = true }
|
||||
@@ -249,6 +250,7 @@ http = { workspace = true }
|
||||
goose-mcp = { path = "../goose-mcp", default-features = false }
|
||||
insta = { version = "1", default-features = false }
|
||||
dtor = { version = "1.0.5", default-features = false, features = ["proc_macro"] }
|
||||
tower = { version = "0.5.2", default-features = false, features = ["util"] }
|
||||
|
||||
[[example]]
|
||||
name = "agent"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
pub 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_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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod connection;
|
||||
pub mod http;
|
||||
pub mod websocket;
|
||||
@@ -101,6 +102,7 @@ fn acp_cors_layer() -> CorsLayer {
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
HeaderName::from_static("x-secret-key"),
|
||||
HeaderName::from_static("acp-connection-id"),
|
||||
HeaderName::from_static("acp-session-id"),
|
||||
header::SEC_WEBSOCKET_VERSION,
|
||||
@@ -127,8 +129,15 @@ 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)
|
||||
pub fn create_router(server: Arc<AcpServer>, secret_key: String, require_token: bool) -> Router {
|
||||
let mut acp_routes = create_acp_routes(server);
|
||||
if require_token {
|
||||
acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state(
|
||||
secret_key.clone(),
|
||||
auth::check_acp_token,
|
||||
));
|
||||
}
|
||||
acp_routes
|
||||
.route("/health", get(health))
|
||||
.route("/status", get(health))
|
||||
.merge(super::mcp_app_proxy::routes(secret_key))
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
|
||||
use goose::acp::transport::create_router;
|
||||
use goose::agents::GoosePlatform;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET: &str = "test-secret-token";
|
||||
|
||||
fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router {
|
||||
let server = Arc::new(AcpServer::new(AcpServerFactoryConfig {
|
||||
builtins: vec![],
|
||||
data_dir: dir.path().join("data"),
|
||||
config_dir: dir.path().join("config"),
|
||||
goose_platform: GoosePlatform::GooseCli,
|
||||
additional_source_roots: Vec::new(),
|
||||
}));
|
||||
create_router(server, SECRET.to_string(), require_token)
|
||||
}
|
||||
|
||||
async fn send(router: &Router, method: Method, uri: &str, headers: &[(&str, &str)]) -> StatusCode {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
for (name, value) in headers {
|
||||
builder = builder.header(*name, *value);
|
||||
}
|
||||
let request = builder.body(Body::empty()).unwrap();
|
||||
router.clone().oneshot(request).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_requests_without_token_are_unauthorized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(true, &dir);
|
||||
|
||||
for method in [Method::GET, Method::POST, Method::DELETE] {
|
||||
let status = send(&router, method.clone(), "/acp", &[]).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED, "method: {method}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_handshake_without_token_is_unauthorized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(true, &dir);
|
||||
|
||||
let status = send(
|
||||
&router,
|
||||
Method::GET,
|
||||
"/acp",
|
||||
&[
|
||||
("connection", "upgrade"),
|
||||
("upgrade", "websocket"),
|
||||
("sec-websocket-version", "13"),
|
||||
("sec-websocket-key", "dGVzdGtleTEyMzQ1Njc4OQ=="),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_token_is_accepted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(true, &dir);
|
||||
|
||||
// 406 (missing Accept: text/event-stream) proves the request passed auth.
|
||||
let status = send(&router, Method::GET, "/acp", &[("X-Secret-Key", SECRET)]).await;
|
||||
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_token_is_accepted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(true, &dir);
|
||||
|
||||
let uri = format!("/acp?token={SECRET}");
|
||||
let status = send(&router, Method::GET, &uri, &[]).await;
|
||||
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_token_is_unauthorized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(true, &dir);
|
||||
|
||||
let status = send(&router, Method::GET, "/acp", &[("X-Secret-Key", "nope")]).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let status = send(&router, Method::GET, "/acp?token=nope", &[]).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_endpoints_skip_token_check() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(true, &dir);
|
||||
|
||||
for path in ["/health", "/status"] {
|
||||
let status = send(&router, Method::GET, path, &[]).await;
|
||||
assert_eq!(status, StatusCode::OK, "path: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_open_when_no_secret_configured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let router = test_router(false, &dir);
|
||||
|
||||
let status = send(&router, Method::GET, "/acp", &[]).await;
|
||||
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
|
||||
}
|
||||
@@ -194,6 +194,18 @@ npm start -- --server http://HOST:PORT
|
||||
cargo run -p goose-cli --bin goose -- serve
|
||||
```
|
||||
|
||||
### Server Authentication
|
||||
|
||||
Set the `GOOSE_SERVER__SECRET_KEY` environment variable to require authentication on the ACP endpoint. When it is set, `goose serve` rejects any request that doesn't present a matching token:
|
||||
|
||||
```bash
|
||||
GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve
|
||||
```
|
||||
|
||||
Clients authenticate by sending the token in the `X-Secret-Key` header, or as a `?token=` query parameter for WebSocket connections (the browser WebSocket API can't set custom headers). Requests without a matching token receive `401 Unauthorized`, including WebSocket handshakes.
|
||||
|
||||
When `GOOSE_SERVER__SECRET_KEY` is not set, the endpoint accepts unauthenticated connections and `goose serve` logs a warning at startup.
|
||||
|
||||
### Single Prompt Mode
|
||||
|
||||
Send a single prompt and exit (useful for scripting):
|
||||
|
||||
@@ -590,7 +590,7 @@ These variables configure the `goosed` server process. They are most often used
|
||||
| `GOOSE_HOST` | Interface the server binds to. Use `0.0.0.0` to accept connections from other machines; `localhost` or `127.0.0.1` restricts to the local machine. | Hostname or IP | `127.0.0.1` |
|
||||
| `GOOSE_PORT` | TCP port the server listens on | Port number | `3000` |
|
||||
| `GOOSE_TLS` | Enable TLS with a self-signed certificate. Required when connecting goose Desktop to a remote `goosed`. | `true`, `false` | `true` |
|
||||
| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests | Secret string | Random (auto-generated) |
|
||||
| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. When set, it is also enforced on the `goose serve` ACP endpoint. | Secret string | Random (auto-generated) |
|
||||
|
||||
**Examples**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user