From bf1a986d5e76a85bcd8d67bf777fcb09eb721534 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 2 Jul 2026 10:37:06 +0200 Subject: [PATCH] Allow local file ACP origins (#10194) --- crates/goose/src/acp/transport/mod.rs | 29 +- crates/goose/tests/acp_transport_auth_test.rs | 292 +++++++++++++++++- 2 files changed, 310 insertions(+), 11 deletions(-) diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index 6c2f1d980b..5030989426 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -21,7 +21,8 @@ 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"; +const OPAQUE_ORIGIN: &str = "null"; +const FILE_ORIGIN: &str = "file://"; #[derive(Clone)] struct AcpOriginPolicy { @@ -51,6 +52,18 @@ impl AcpOriginPolicy { } } + fn local_default() -> Self { + Self::loopback_and(Self::file_origins(Vec::new())) + } + + fn file_origins(mut origins: Vec) -> Vec { + origins.extend([ + HeaderValue::from_static(OPAQUE_ORIGIN), + HeaderValue::from_static(FILE_ORIGIN), + ]); + origins + } + fn origin_allowed(&self, origin: &HeaderValue) -> bool { if self .exact_origins @@ -204,11 +217,7 @@ pub fn create_acp_router(server: Arc) -> Router { } pub fn create_authenticated_acp_router(server: Arc, secret_key: String) -> Router { - create_acp_router_with_policy( - server, - AcpOriginPolicy::loopback_and(vec![HeaderValue::from_static(DESKTOP_FILE_ORIGIN)]), - Some(secret_key), - ) + create_acp_router_with_policy(server, AcpOriginPolicy::local_default(), Some(secret_key)) } async fn health() -> &'static str { @@ -223,10 +232,12 @@ pub fn create_router( require_token: bool, additional_allowed_origins: Vec, ) -> Router { - let policy = if additional_allowed_origins.is_empty() { - AcpOriginPolicy::loopback() - } else { + let policy = if !additional_allowed_origins.is_empty() { AcpOriginPolicy::exact(additional_allowed_origins) + } else if require_token { + AcpOriginPolicy::local_default() + } else { + AcpOriginPolicy::loopback() }; let acp_routes = create_acp_router_with_policy(server, policy, require_token.then_some(secret_key.clone())); diff --git a/crates/goose/tests/acp_transport_auth_test.rs b/crates/goose/tests/acp_transport_auth_test.rs index 32198be829..5e360df3e9 100644 --- a/crates/goose/tests/acp_transport_auth_test.rs +++ b/crates/goose/tests/acp_transport_auth_test.rs @@ -152,6 +152,30 @@ async fn acp_router_websocket_handshake_rejects_arbitrary_web_origins() { assert_eq!(status, StatusCode::FORBIDDEN); } +#[tokio::test] +async fn acp_router_websocket_handshake_rejects_file_origins_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_acp_router(&dir); + + for origin in ["null", "file://"] { + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", origin), + ("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(); @@ -175,7 +199,73 @@ async fn authenticated_acp_router_allows_packaged_desktop_null_websocket_origin( } #[tokio::test] -async fn serve_router_rejects_null_websocket_origin_by_default() { +async fn authenticated_acp_router_allows_packaged_desktop_file_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", "file://"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn authenticated_serve_router_allows_null_websocket_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &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 authenticated_serve_router_allows_file_websocket_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + let status = send( + &router, + Method::GET, + &format!("/acp?token={SECRET}"), + &[ + ("origin", "file://"), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn unauthenticated_serve_router_rejects_null_websocket_origin_by_default() { let dir = tempfile::tempdir().unwrap(); let router = test_router(false, &dir); @@ -196,6 +286,28 @@ async fn serve_router_rejects_null_websocket_origin_by_default() { assert_eq!(status, StatusCode::FORBIDDEN); } +#[tokio::test] +async fn unauthenticated_serve_router_rejects_file_websocket_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", "file://"), + ("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(); @@ -292,6 +404,34 @@ async fn websocket_handshake_allows_configured_origins() { assert_eq!(status, StatusCode::NOT_ACCEPTABLE); } +#[tokio::test] +async fn websocket_handshake_explicit_origins_replace_file_defaults() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + for origin in ["null", "file://"] { + let status = send( + &router, + Method::GET, + "/acp", + &[ + ("origin", origin), + ("connection", "upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + } +} + #[tokio::test] async fn header_token_is_accepted() { let dir = tempfile::tempdir().unwrap(); @@ -515,7 +655,97 @@ async fn authenticated_acp_cors_allows_packaged_desktop_null_origin() { } #[tokio::test] -async fn serve_cors_rejects_null_origin_by_default() { +async fn authenticated_acp_cors_allows_packaged_desktop_file_origin() { + let dir = tempfile::tempdir().unwrap(); + let router = test_authenticated_acp_router(&dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "file://"), + ("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("file://") + ); +} + +#[tokio::test] +async fn authenticated_serve_cors_allows_null_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &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 authenticated_serve_cors_allows_file_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(true, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "file://"), + ("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("file://") + ); +} + +#[tokio::test] +async fn unauthenticated_serve_cors_rejects_null_origin_by_default() { let dir = tempfile::tempdir().unwrap(); let router = test_router(false, &dir); @@ -540,6 +770,32 @@ async fn serve_cors_rejects_null_origin_by_default() { .is_none()); } +#[tokio::test] +async fn unauthenticated_serve_cors_rejects_file_origin_by_default() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router(false, &dir); + + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", "file://"), + ("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(); @@ -602,3 +858,35 @@ async fn acp_cors_allows_additional_configured_origins() { Some("app://localhost") ); } + +#[tokio::test] +async fn acp_cors_explicit_origins_replace_file_defaults() { + let dir = tempfile::tempdir().unwrap(); + let router = test_router_with_origins( + false, + &dir, + vec![HeaderValue::from_static("app://localhost")], + ); + + for origin in ["null", "file://"] { + let response = send_response( + &router, + Method::OPTIONS, + "/acp", + &[ + ("Origin", origin), + ("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()); + } +}