Improve ACP auth and origin defaults (#9886)

This commit is contained in:
Jasper 2026-07-02 00:08:46 +02:00 committed by GitHub
parent 7026e14585
commit a162a7f778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 806 additions and 42 deletions

View file

@ -849,6 +849,20 @@ enum Command {
action = clap::ArgAction::Append
)]
builtins: Vec<String>,
#[arg(
long = "dangerously-unauthenticated",
help = "Start the ACP endpoint without requiring GOOSE_SERVER__SECRET_KEY"
)]
dangerously_unauthenticated: bool,
#[arg(
long = "allowed-origin",
value_name = "ORIGIN",
action = clap::ArgAction::Append,
help = "Allow an exact Origin value for ACP CORS; may be specified multiple times and replaces the default loopback origins"
)]
allowed_origins: Vec<String>,
},
/// Start or resume interactive chat sessions
@ -1342,14 +1356,19 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> {
Ok(())
}
async fn handle_serve_command(
struct ServeCommandArgs {
host: String,
port: u16,
tls: bool,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
builtins: Vec<String>,
) -> Result<()> {
dangerously_unauthenticated: bool,
allowed_origins: Vec<String>,
}
async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> {
use axum::http::HeaderValue;
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use goose::acp::transport::create_router;
use goose::config::paths::Paths;
@ -1357,6 +1376,17 @@ async fn handle_serve_command(
use std::sync::Arc;
use tracing::{info, warn};
let ServeCommandArgs {
host,
port,
tls,
tls_cert_path,
tls_key_path,
builtins,
dangerously_unauthenticated,
allowed_origins,
} = args;
let builtins = if builtins.is_empty() {
vec!["developer".to_string()]
} else {
@ -1388,13 +1418,35 @@ async fn handle_serve_command(
.map(|secret| secret.trim().to_string())
.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"
if !require_token && !dangerously_unauthenticated {
anyhow::bail!(
"{GOOSE_SERVER_SECRET_KEY_ENV} must be set to start `goose serve`; pass --dangerously-unauthenticated to run without ACP authentication"
);
}
if dangerously_unauthenticated && !require_token {
warn!(
"{GOOSE_SERVER_SECRET_KEY_ENV} is not set and --dangerously-unauthenticated was passed; the ACP endpoint will accept unauthenticated connections"
);
}
let additional_allowed_origins = allowed_origins
.into_iter()
.map(|origin| {
let origin = origin.trim();
if origin.is_empty() || origin == "*" {
anyhow::bail!("--allowed-origin must be a non-wildcard Origin value");
}
HeaderValue::from_str(origin).map_err(|error| {
anyhow::anyhow!("invalid --allowed-origin value `{origin}`: {error}")
})
})
.collect::<Result<Vec<_>>>()?;
let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key);
let router = create_router(server, secret_key, require_token);
let router = create_router(
server,
secret_key,
require_token,
additional_allowed_origins,
);
let config = Config::global();
let tls_cert_path =
@ -2159,7 +2211,21 @@ pub async fn cli() -> anyhow::Result<()> {
tls_cert_path,
tls_key_path,
builtins,
}) => handle_serve_command(host, port, tls, tls_cert_path, tls_key_path, builtins).await,
dangerously_unauthenticated,
allowed_origins,
}) => {
handle_serve_command(ServeCommandArgs {
host,
port,
tls,
tls_cert_path,
tls_key_path,
builtins,
dangerously_unauthenticated,
allowed_origins,
})
.await
}
Some(Command::Session {
command: Some(cmd), ..
}) => handle_session_subcommand(cmd).await,
@ -2356,6 +2422,35 @@ mod tests {
}
}
#[test]
fn serve_command_accepts_dangerously_unauthenticated_flag() {
let cli = Cli::try_parse_from([
"goose",
"serve",
"--dangerously-unauthenticated",
"--allowed-origin",
"app://localhost",
"--allowed-origin",
"https://app.example",
])
.expect("parse failed");
match cli.command {
Some(Command::Serve {
dangerously_unauthenticated,
allowed_origins,
..
}) => {
assert!(dangerously_unauthenticated);
assert_eq!(
allowed_origins,
vec!["app://localhost", "https://app.example"]
);
}
_ => panic!("expected serve command"),
}
}
#[test]
fn review_command_accepts_options() {
let cli = Cli::try_parse_from([

View file

@ -4,10 +4,10 @@ use anyhow::Result;
use axum::middleware;
use axum_server::Handle;
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use goose::acp::transport::create_acp_router;
use goose::acp::transport::create_authenticated_acp_router;
use goose::agents::GoosePlatform;
use goose::config::paths::Paths;
use goose_server::auth::{check_acp_token, check_token};
use goose_server::auth::check_token;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
@ -72,15 +72,15 @@ pub async fn run() -> Result<()> {
scheduler: Some(app_state.scheduler()),
}));
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 rest_router = crate::routes::configure(app_state.clone(), secret_key.clone())
.layer(middleware::from_fn_with_state(
secret_key.clone(),
check_token,
))
.layer(cors);
let acp_router = create_authenticated_acp_router(acp_server, secret_key.clone());
let app = rest_router.merge(acp_router).layer(cors);
let app = rest_router.merge(acp_router);
let addr = settings.socket_addr();

View file

@ -6,26 +6,161 @@ use std::sync::Arc;
use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions};
use axum::{
http::{header, HeaderName, Method},
extract::{Request, State},
http::{header, HeaderName, HeaderValue, Method, StatusCode},
middleware::Next,
response::Response,
routing::get,
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use crate::acp::server::GooseAgentConnection;
use crate::acp::server_factory::AcpServer;
// The upstream ACP HTTP server only supports exact origin allowlists for
// WebSocket upgrades; Goose applies its richer loopback predicate before this.
const UPSTREAM_WS_ALLOWED_ORIGIN: &str = "http://goose.local";
const DESKTOP_FILE_ORIGIN: &str = "null";
#[derive(Clone)]
struct AcpOriginPolicy {
exact_origins: Arc<[HeaderValue]>,
allow_loopback: bool,
}
impl AcpOriginPolicy {
fn loopback() -> Self {
Self {
exact_origins: Vec::new().into(),
allow_loopback: true,
}
}
fn exact(origins: Vec<HeaderValue>) -> Self {
Self {
exact_origins: origins.into(),
allow_loopback: false,
}
}
fn loopback_and(origins: Vec<HeaderValue>) -> Self {
Self {
exact_origins: origins.into(),
allow_loopback: true,
}
}
fn origin_allowed(&self, origin: &HeaderValue) -> bool {
if self
.exact_origins
.iter()
.any(|allowed_origin| allowed_origin == origin)
{
return true;
}
if !self.allow_loopback {
return false;
}
let Ok(origin) = origin.to_str() else {
return false;
};
let Ok(url) = url::Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
match url.host() {
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
None => false,
}
}
}
fn acp_http_options() -> ServerOptions {
ServerOptions {
path: "/acp".to_string(),
cors: CorsOptions::allow_any_origin(),
cors: CorsOptions::allow_origins([UPSTREAM_WS_ALLOWED_ORIGIN])
.expect("static origin is valid"),
health_endpoint: false,
}
}
fn header_contains_token(value: Option<&HeaderValue>, token: &str) -> bool {
value
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(token))
})
}
fn is_websocket_upgrade(request: &Request) -> bool {
request.method() == Method::GET
&& header_contains_token(request.headers().get(header::CONNECTION), "upgrade")
&& request
.headers()
.get(header::UPGRADE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"))
}
async fn enforce_websocket_origin(
State(policy): State<AcpOriginPolicy>,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if is_websocket_upgrade(&request) {
if let Some(origin) = request.headers().get(header::ORIGIN) {
if !policy.origin_allowed(origin) {
return Err(StatusCode::FORBIDDEN);
}
}
request.headers_mut().insert(
header::ORIGIN,
HeaderValue::from_static(UPSTREAM_WS_ALLOWED_ORIGIN),
);
}
Ok(next.run(request).await)
}
fn acp_cors_layer(policy: AcpOriginPolicy) -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::predicate(move |origin, _request_parts| {
policy.origin_allowed(origin)
}))
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.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,
header::SEC_WEBSOCKET_KEY,
header::CONNECTION,
header::UPGRADE,
])
.expose_headers([
HeaderName::from_static("acp-connection-id"),
HeaderName::from_static("acp-session-id"),
])
}
/// CORS for the auxiliary routes (`/health`, `/status`, MCP app proxy) served by
/// `goose serve`. The ACP routes get their CORS from `AcpHttpServer`; this also
/// allows the `x-secret-key` auth header the proxy routes rely on.
/// `goose serve`. This allows the `x-secret-key` auth header the proxy routes
/// rely on.
fn aux_cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(Any)
@ -37,12 +172,43 @@ fn aux_cors_layer() -> CorsLayer {
])
}
/// The bare ACP HTTP/WebSocket router (POST/GET/DELETE on `/acp`), without auth
/// or goose-specific auxiliary routes.
pub fn create_acp_router(server: Arc<AcpServer>) -> Router {
fn create_acp_router_inner(server: Arc<AcpServer>, policy: AcpOriginPolicy) -> Router {
AcpHttpServer::new(move || GooseAgentConnection::new(server.clone()))
.with_options(acp_http_options())
.into_router()
.layer(axum::middleware::from_fn_with_state(
policy,
enforce_websocket_origin,
))
}
fn create_acp_router_with_policy(
server: Arc<AcpServer>,
policy: AcpOriginPolicy,
secret_key: Option<String>,
) -> Router {
let mut acp_routes = create_acp_router_inner(server, policy.clone());
if let Some(secret_key) = secret_key {
acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state(
secret_key,
auth::check_acp_token,
));
}
acp_routes.layer(acp_cors_layer(policy))
}
/// The bare ACP HTTP/WebSocket router (POST/GET/DELETE on `/acp`), without auth
/// or goose-specific auxiliary routes.
pub fn create_acp_router(server: Arc<AcpServer>) -> Router {
create_acp_router_with_policy(server, AcpOriginPolicy::loopback(), None)
}
pub fn create_authenticated_acp_router(server: Arc<AcpServer>, secret_key: String) -> Router {
create_acp_router_with_policy(
server,
AcpOriginPolicy::loopback_and(vec![HeaderValue::from_static(DESKTOP_FILE_ORIGIN)]),
Some(secret_key),
)
}
async fn health() -> &'static str {
@ -51,14 +217,19 @@ async fn health() -> &'static str {
/// The full standalone ACP server router used by `goose serve`: ACP transport,
/// optional token auth, health/status endpoints, and the MCP app proxy.
pub fn create_router(server: Arc<AcpServer>, secret_key: String, require_token: bool) -> Router {
let mut acp_routes = create_acp_router(server);
if require_token {
acp_routes = acp_routes.layer(axum::middleware::from_fn_with_state(
secret_key.clone(),
auth::check_acp_token,
));
}
pub fn create_router(
server: Arc<AcpServer>,
secret_key: String,
require_token: bool,
additional_allowed_origins: Vec<HeaderValue>,
) -> Router {
let policy = if additional_allowed_origins.is_empty() {
AcpOriginPolicy::loopback()
} else {
AcpOriginPolicy::exact(additional_allowed_origins)
};
let acp_routes =
create_acp_router_with_policy(server, policy, require_token.then_some(secret_key.clone()));
let aux_routes = Router::new()
.route("/health", get(health))

View file

@ -1,16 +1,20 @@
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::http::{HeaderValue, Method, Request, Response, StatusCode};
use axum::Router;
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use goose::acp::transport::create_router;
use goose::acp::transport::{create_acp_router, create_authenticated_acp_router, 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 {
test_router_with_origins(require_token, dir, Vec::new())
}
fn test_acp_router(dir: &tempfile::TempDir) -> Router {
let server = Arc::new(AcpServer::new(AcpServerFactoryConfig {
builtins: vec![],
data_dir: dir.path().join("data"),
@ -19,16 +23,58 @@ fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router {
additional_source_roots: Vec::new(),
scheduler: None,
}));
create_router(server, SECRET.to_string(), require_token)
create_acp_router(server)
}
fn test_authenticated_acp_router(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(),
scheduler: None,
}));
create_authenticated_acp_router(server, SECRET.to_string())
}
fn test_router_with_origins(
require_token: bool,
dir: &tempfile::TempDir,
additional_allowed_origins: Vec<HeaderValue>,
) -> 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(),
scheduler: None,
}));
create_router(
server,
SECRET.to_string(),
require_token,
additional_allowed_origins,
)
}
async fn send(router: &Router, method: Method, uri: &str, headers: &[(&str, &str)]) -> StatusCode {
send_response(router, method, uri, headers).await.status()
}
async fn send_response(
router: &Router,
method: Method,
uri: &str,
headers: &[(&str, &str)],
) -> Response<Body> {
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()
router.clone().oneshot(request).await.unwrap()
}
#[tokio::test]
@ -62,6 +108,190 @@ async fn websocket_handshake_without_token_is_unauthorized() {
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn websocket_handshake_rejects_arbitrary_web_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "https://evil.example"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn acp_router_websocket_handshake_rejects_arbitrary_web_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_acp_router(&dir);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "https://evil.example"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn authenticated_acp_router_allows_packaged_desktop_null_websocket_origin() {
let dir = tempfile::tempdir().unwrap();
let router = test_authenticated_acp_router(&dir);
let status = send(
&router,
Method::GET,
&format!("/acp?token={SECRET}"),
&[
("origin", "null"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn serve_router_rejects_null_websocket_origin_by_default() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "null"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn websocket_handshake_allows_loopback_web_origins_by_default() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "http://localhost:5173"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn websocket_handshake_allows_ipv6_loopback_web_origins_by_default() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "http://[::1]:5173"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn websocket_handshake_explicit_origins_replace_loopback_defaults() {
let dir = tempfile::tempdir().unwrap();
let router = test_router_with_origins(
false,
&dir,
vec![HeaderValue::from_static("app://localhost")],
);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "http://localhost:5173"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn websocket_handshake_allows_configured_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_router_with_origins(
false,
&dir,
vec![HeaderValue::from_static("app://localhost")],
);
let status = send(
&router,
Method::GET,
"/acp",
&[
("origin", "app://localhost"),
("connection", "upgrade"),
("upgrade", "websocket"),
("sec-websocket-version", "13"),
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
],
)
.await;
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn header_token_is_accepted() {
let dir = tempfile::tempdir().unwrap();
@ -106,10 +336,269 @@ async fn health_endpoints_skip_token_check() {
}
#[tokio::test]
async fn acp_open_when_no_secret_configured() {
async fn acp_open_when_auth_disabled() {
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);
}
#[tokio::test]
async fn acp_cors_rejects_arbitrary_web_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "https://evil.example"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,acp-connection-id",
),
],
)
.await;
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn acp_cors_rejects_custom_app_origins_unless_configured() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "app://localhost"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,acp-connection-id",
),
],
)
.await;
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn acp_cors_allows_loopback_web_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "http://localhost:5173"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,acp-connection-id",
),
],
)
.await;
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|value| value.to_str().ok()),
Some("http://localhost:5173")
);
}
#[tokio::test]
async fn acp_cors_allows_ipv6_loopback_web_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "http://[::1]:5173"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,acp-connection-id",
),
],
)
.await;
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|value| value.to_str().ok()),
Some("http://[::1]:5173")
);
}
#[tokio::test]
async fn authenticated_acp_cors_preflight_skips_token_check() {
let dir = tempfile::tempdir().unwrap();
let router = test_authenticated_acp_router(&dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "http://localhost:5173"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,x-secret-key,acp-connection-id",
),
],
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|value| value.to_str().ok()),
Some("http://localhost:5173")
);
}
#[tokio::test]
async fn authenticated_acp_cors_allows_packaged_desktop_null_origin() {
let dir = tempfile::tempdir().unwrap();
let router = test_authenticated_acp_router(&dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "null"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,x-secret-key,acp-connection-id",
),
],
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|value| value.to_str().ok()),
Some("null")
);
}
#[tokio::test]
async fn serve_cors_rejects_null_origin_by_default() {
let dir = tempfile::tempdir().unwrap();
let router = test_router(false, &dir);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "null"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,x-secret-key,acp-connection-id",
),
],
)
.await;
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn acp_cors_explicit_origins_replace_loopback_defaults() {
let dir = tempfile::tempdir().unwrap();
let router = test_router_with_origins(
false,
&dir,
vec![HeaderValue::from_static("app://localhost")],
);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "http://localhost:5173"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,acp-connection-id",
),
],
)
.await;
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn acp_cors_allows_additional_configured_origins() {
let dir = tempfile::tempdir().unwrap();
let router = test_router_with_origins(
false,
&dir,
vec![HeaderValue::from_static("app://localhost")],
);
let response = send_response(
&router,
Method::OPTIONS,
"/acp",
&[
("Origin", "app://localhost"),
("Access-Control-Request-Method", "POST"),
(
"Access-Control-Request-Headers",
"content-type,acp-connection-id",
),
],
)
.await;
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|value| value.to_str().ok()),
Some("app://localhost")
);
}

View file

@ -204,12 +204,12 @@ For servers that support the draft standard ACP over Streamable HTTP https://git
npm start -- --server http://HOST:PORT
# example server
cargo run -p goose-cli --bin goose -- serve
GOOSE_SERVER__SECRET_KEY='a-long-random-secret' 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:
Set the `GOOSE_SERVER__SECRET_KEY` environment variable to authenticate the ACP endpoint. `goose serve` refuses to start without this secret unless you explicitly pass `--dangerously-unauthenticated`:
```bash
GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve
@ -217,7 +217,16 @@ 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.
ACP WebSocket Origin validation allows loopback web origins by default. For `goose serve`, ACP CORS follows the same policy. If you pass any `--allowed-origin` values, that explicit list replaces the default loopback origins, so include every origin the client needs:
```bash
GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve \
--allowed-origin 'http://localhost:5173' \
--allowed-origin 'app://localhost' \
--allowed-origin 'https://app.example'
```
For local development only, `goose serve --dangerously-unauthenticated` starts without a secret and logs a warning. Do not use this mode with shell-capable builtins enabled unless the server is isolated from untrusted browser traffic.
### Single Prompt Mode

View file

@ -514,7 +514,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. When set, it is also enforced on the `goose serve` ACP endpoint. | Secret string | Random (auto-generated) |
| `GOOSE_SERVER__SECRET_KEY` | Shared secret required in the `X-Secret-Key` header on all client requests. `goosed` auto-generates one when unset; `goose serve` requires this variable unless started with `--dangerously-unauthenticated`. | Secret string | Random for `goosed`; required for `goose serve` |
**Examples**