diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ee89f6243f..3ca1e5e967 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -618,10 +618,6 @@ "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "askama_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"askama_macros\",\"optional\":true,\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"itoa\",\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"askama_macros?/alloc\",\"serde?/alloc\",\"serde_json?/alloc\",\"percent-encoding?/alloc\"],\"code-in-doc\":[\"askama_macros?/code-in-doc\"],\"config\":[\"askama_macros?/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[\"dep:askama_macros\",\"dep:askama_macros\"],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_macros/nightly-spans\"],\"serde_json\":[\"std\",\"askama_macros?/serde_json\",\"dep:serde\",\"dep:serde_json\"],\"std\":[\"alloc\",\"askama_macros?/std\",\"serde?/std\",\"serde_json?/std\",\"percent-encoding?/std\"],\"urlencode\":[\"askama_macros?/urlencode\",\"dep:percent-encoding\"]}}", - "askama_derive_0.15.4": "{\"dependencies\":[{\"name\":\"basic-toml\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2\"},{\"name\":\"parser\",\"package\":\"askama_parser\",\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.6.0\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.3\"}],\"features\":{\"alloc\":[],\"code-in-doc\":[\"dep:pulldown-cmark\"],\"config\":[\"external-sources\",\"dep:basic-toml\",\"dep:serde\",\"dep:serde_derive\",\"parser/config\"],\"default\":[\"alloc\",\"code-in-doc\",\"config\",\"external-sources\",\"proc-macro\",\"serde_json\",\"std\",\"urlencode\"],\"external-sources\":[],\"nightly-spans\":[],\"proc-macro\":[\"proc-macro2/proc-macro\"],\"serde_json\":[],\"std\":[\"alloc\"],\"urlencode\":[]}}", - "askama_macros_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"external-sources\",\"proc-macro\"],\"name\":\"askama_derive\",\"package\":\"askama_derive\",\"req\":\"=0.15.4\"}],\"features\":{\"alloc\":[\"askama_derive/alloc\"],\"code-in-doc\":[\"askama_derive/code-in-doc\"],\"config\":[\"askama_derive/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_derive/nightly-spans\"],\"serde_json\":[\"askama_derive/serde_json\"],\"std\":[\"askama_derive/std\"],\"urlencode\":[\"askama_derive/urlencode\"]}}", - "askama_parser_0.15.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0.12\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.0\"}],\"features\":{\"config\":[\"dep:serde\",\"dep:serde_derive\"]}}", "asn1-rs-derive_0.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"synstructure\",\"req\":\"^0.13\"}],\"features\":{}}", "asn1-rs-impl_0.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "asn1-rs_0.7.1": "{\"dependencies\":[{\"name\":\"asn1-rs-derive\",\"req\":\"^0.6\"},{\"name\":\"asn1-rs-impl\",\"req\":\"^0.2\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"colored\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^3.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"displaydoc\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pem\",\"req\":\"^3.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"macros\",\"parsing\",\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"bigint\":[\"num-bigint\"],\"bits\":[\"bitvec\"],\"datetime\":[\"time\"],\"debug\":[\"std\",\"colored\"],\"default\":[\"std\"],\"serialize\":[\"cookie-factory\"],\"std\":[],\"trace\":[\"debug\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 02625d90c2..7a554c2bcf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -481,58 +481,6 @@ dependencies = [ "term", ] -[[package]] -name = "askama" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" -dependencies = [ - "askama_macros", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash 2.1.1", - "serde", - "serde_derive", - "syn 2.0.114", -] - -[[package]] -name = "askama_macros" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" -dependencies = [ - "askama_derive", -] - -[[package]] -name = "askama_parser" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" -dependencies = [ - "rustc-hash 2.1.1", - "serde", - "serde_derive", - "unicode-ident", - "winnow", -] - [[package]] name = "asn1-rs" version = "0.7.1" @@ -1595,6 +1543,7 @@ dependencies = [ "anyhow", "codex-apply-patch", "codex-linux-sandbox", + "codex-sandboxing", "codex-shell-escalation", "codex-utils-home-dir", "dotenvy", @@ -1880,7 +1829,6 @@ version = "0.0.0" dependencies = [ "anyhow", "arc-swap", - "askama", "assert_cmd", "assert_matches", "async-channel", @@ -1895,7 +1843,6 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-arg0", - "codex-artifacts", "codex-async-utils", "codex-code-mode", "codex-config", @@ -1933,6 +1880,7 @@ dependencies = [ "codex-utils-readiness", "codex-utils-stream-parser", "codex-utils-string", + "codex-utils-template", "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", @@ -2252,6 +2200,7 @@ dependencies = [ "clap", "codex-core", "codex-protocol", + "codex-sandboxing", "codex-utils-absolute-path", "landlock", "libc", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f19fb650f7..556976fc1a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -189,7 +189,6 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.15.4" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" @@ -397,6 +396,7 @@ unwrap_used = "deny" ignored = [ "icu_provider", "openssl-sys", + "codex-artifacts", "codex-utils-readiness", "codex-utils-template", "codex-v8-poc", diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1ea9f6fd87..ad9eca65e7 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -868,8 +868,11 @@ mod tests { use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; - use tokio_tungstenite::accept_async; + use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest; + use tokio_tungstenite::tungstenite::handshake::server::Response as WebSocketResponse; + use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; async fn build_test_config() -> Config { match ConfigBuilder::default().build().await { @@ -908,6 +911,19 @@ mod tests { } async fn start_test_remote_server(handler: F) -> String + where + F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + + Send + + 'static, + Fut: std::future::Future + Send + 'static, + { + start_test_remote_server_with_auth(None, handler).await + } + + async fn start_test_remote_server_with_auth( + expected_auth_token: Option, + handler: F, + ) -> String where F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + Send @@ -920,9 +936,23 @@ mod tests { let addr = listener.local_addr().expect("listener address"); tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("accept should succeed"); - let websocket = accept_async(stream) - .await - .expect("websocket upgrade should succeed"); + let websocket = accept_hdr_async( + stream, + move |request: &WebSocketRequest, response: WebSocketResponse| { + let provided_auth_token = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let expected_auth_token = expected_auth_token + .as_ref() + .map(|token| format!("Bearer {token}")); + assert_eq!(provided_auth_token, expected_auth_token); + Ok(response) + }, + ) + .await + .expect("websocket upgrade should succeed"); handler(websocket).await; }); format!("ws://{addr}") @@ -1037,6 +1067,7 @@ mod tests { fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { RemoteAppServerConnectArgs { websocket_url, + auth_token: None, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, @@ -1253,6 +1284,7 @@ mod tests { }), ) .await; + websocket.close(None).await.expect("close should succeed"); }) .await; let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) @@ -1273,6 +1305,59 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_connect_includes_auth_header_when_configured() { + let auth_token = "remote-bearer-token".to_string(); + let websocket_url = start_test_remote_server_with_auth( + Some(auth_token.clone()), + |mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + websocket.close(None).await.expect("close should succeed"); + }, + ) + .await; + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + auth_token: Some(auth_token), + ..test_remote_connect_args(websocket_url) + }) + .await + .expect("remote client should connect"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() { + let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url: "ws://example.com:4500".to_string(), + auth_token: Some("remote-bearer-token".to_string()), + ..test_remote_connect_args("ws://127.0.0.1:1".to_string()) + }) + .await; + let err = match result { + Ok(_) => panic!("non-loopback ws should be rejected before connect"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("remote auth tokens require `wss://` or loopback `ws://` URLs") + ); + } + + #[test] + fn remote_auth_token_transport_policy_allows_wss_and_loopback_ws() { + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("wss://example.com:443").expect("wss URL should parse") + )); + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://127.0.0.1:4500").expect("loopback ws URL should parse") + )); + assert!(!crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://example.com:4500").expect("non-loopback ws URL should parse") + )); + } + #[tokio::test] async fn remote_duplicate_request_id_keeps_original_waiter() { let (first_request_seen_tx, first_request_seen_rx) = tokio::sync::oneshot::channel(); @@ -1425,6 +1510,7 @@ mod tests { .await; let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { websocket_url, + auth_token: None, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 9cf37e262f..a82b924e45 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -48,6 +48,9 @@ use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tracing::warn; use url::Url; @@ -57,6 +60,7 @@ const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Clone)] pub struct RemoteAppServerConnectArgs { pub websocket_url: String, + pub auth_token: Option, pub client_name: String, pub client_version: String, pub experimental_api: bool, @@ -86,6 +90,16 @@ impl RemoteAppServerConnectArgs { } } +pub(crate) fn websocket_url_supports_auth_token(url: &Url) -> bool { + match (url.scheme(), url.host()) { + ("wss", Some(_)) => true, + ("ws", Some(url::Host::Domain(domain))) => domain.eq_ignore_ascii_case("localhost"), + ("ws", Some(url::Host::Ipv4(addr))) => addr.is_loopback(), + ("ws", Some(url::Host::Ipv6(addr))) => addr.is_loopback(), + _ => false, + } +} + enum RemoteClientCommand { Request { request: Box, @@ -132,7 +146,31 @@ impl RemoteAppServerClient { format!("invalid websocket URL `{websocket_url}`: {err}"), ) })?; - let stream = timeout(CONNECT_TIMEOUT, connect_async(url.as_str())) + if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!( + "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" + ), + )); + } + let mut request = url.as_str().into_client_request().map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if let Some(auth_token) = args.auth_token.as_deref() { + let header_value = + HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid remote authorization header value: {err}"), + ) + })?; + request.headers_mut().insert(AUTHORIZATION, header_value); + } + let stream = timeout(CONNECT_TIMEOUT, connect_async(request)) .await .map_err(|_| { IoError::new( diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 37f7ff8c5f..8ca93137c6 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1206,6 +1206,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d4269f5239..6253c39297 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8221,6 +8221,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index acf36d6c3c..5d053604f5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4892,6 +4892,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 881c343601..bce797086c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -4,6 +4,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 18fdb5008d..72f32d0d9d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -4,6 +4,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index b75ee3930a..b97c709b98 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 67c4e95856..147c91db2c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -377,7 +377,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, SessionStart, UserPromptSubmit, Stop + PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop } ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a1856af46d..1ca4688272 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1012,6 +1012,11 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +For MCP tool approval elicitations, form request `meta` includes +`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, +`persist: "always"`, or `persist: ["session", "always"]` to advertise whether +the client can offer session-scoped and/or persistent approval choices. + ### Permission requests The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the standalone tool's narrower permission shape, so it can request network access and additional filesystem access but does not include the broader `macos` branch used by command-execution `additionalPermissions`. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4687ec5ba3..620a85a095 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -537,6 +537,7 @@ impl CodexMessageProcessor { data: None, })?; apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + config.codex_self_exe = self.arg0_paths.codex_self_exe.clone(); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); Ok(config) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 51f17b1162..00062128f3 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -131,7 +131,7 @@ impl ConfigApi { .unwrap_or_default() } - async fn load_latest_config( + pub(crate) async fn load_latest_config( &self, fallback_cwd: Option, ) -> Result { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0a2b6f386b..e85a8f1290 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -363,38 +363,6 @@ pub async fn run_main_with_transport( let (outbound_control_tx, mut outbound_control_rx) = mpsc::channel::(CHANNEL_CAPACITY); - enum TransportRuntime { - Stdio, - WebSocket { - accept_handle: JoinHandle<()>, - shutdown_token: CancellationToken, - }, - } - - let mut stdio_handles = Vec::>::new(); - let transport_runtime = match transport { - AppServerTransport::Stdio => { - start_stdio_connection(transport_event_tx.clone(), &mut stdio_handles).await?; - TransportRuntime::Stdio - } - AppServerTransport::WebSocket { bind_address } => { - let shutdown_token = CancellationToken::new(); - let accept_handle = start_websocket_acceptor( - bind_address, - transport_event_tx.clone(), - shutdown_token.clone(), - policy_from_settings(&auth)?, - ) - .await?; - TransportRuntime::WebSocket { - accept_handle, - shutdown_token, - } - } - }; - let single_client_mode = matches!(&transport_runtime, TransportRuntime::Stdio); - let shutdown_when_no_connections = single_client_mode; - let graceful_signal_restart_enabled = !single_client_mode; // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { @@ -558,6 +526,30 @@ pub async fn run_main_with_transport( } } + let transport_shutdown_token = CancellationToken::new(); + let mut transport_accept_handles = Vec::>::new(); + + let single_client_mode = matches!(&transport, AppServerTransport::Stdio); + let shutdown_when_no_connections = single_client_mode; + let graceful_signal_restart_enabled = !single_client_mode; + + match transport { + AppServerTransport::Stdio => { + start_stdio_connection(transport_event_tx.clone(), &mut transport_accept_handles) + .await?; + } + AppServerTransport::WebSocket { bind_address } => { + let accept_handle = start_websocket_acceptor( + bind_address, + transport_event_tx.clone(), + transport_shutdown_token.clone(), + policy_from_settings(&auth)?, + ) + .await?; + transport_accept_handles.push(accept_handle); + } + } + let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); loop { @@ -635,10 +627,7 @@ pub async fn run_main_with_transport( let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); let mut connections = HashMap::::new(); - let websocket_accept_shutdown = match &transport_runtime { - TransportRuntime::WebSocket { shutdown_token, .. } => Some(shutdown_token.clone()), - TransportRuntime::Stdio => None, - }; + let transport_shutdown_token = transport_shutdown_token.clone(); async move { let mut listen_for_threads = true; let mut shutdown_state = ShutdownState::default(); @@ -651,9 +640,7 @@ pub async fn run_main_with_transport( shutdown_state.update(running_turn_count, connections.len()), ShutdownAction::Finish ) { - if let Some(shutdown_token) = &websocket_accept_shutdown { - shutdown_token.cancel(); - } + transport_shutdown_token.cancel(); let _ = outbound_control_tx .send(OutboundControlEvent::DisconnectAll) .await; @@ -847,16 +834,8 @@ pub async fn run_main_with_transport( let _ = processor_handle.await; let _ = outbound_handle.await; - if let TransportRuntime::WebSocket { - accept_handle, - shutdown_token, - } = transport_runtime - { - shutdown_token.cancel(); - let _ = accept_handle.await; - } - - for handle in stdio_handles { + transport_shutdown_token.cancel(); + for handle in transport_accept_handles { let _ = handle.await; } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 9a9a145f65..301ef9992c 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -20,6 +20,7 @@ use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::RequestContext; use crate::transport::AppServerTransport; use async_trait::async_trait; +use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; @@ -53,6 +54,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; +use codex_chatgpt::connectors; use codex_core::AnalyticsEventsClient; use codex_core::AuthManager; use codex_core::ThreadManager; @@ -880,13 +882,87 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureEnablementSetParams, ) { - self.handle_config_mutation_result( - request_id, - self.config_api - .set_experimental_feature_enablement(params) - .await, - ) - .await; + let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); + match self + .config_api + .set_experimental_feature_enablement(params) + .await + { + Ok(response) => { + self.codex_message_processor.clear_plugin_related_caches(); + self.codex_message_processor + .maybe_start_plugin_startup_tasks_for_latest_config() + .await; + self.outgoing.send_response(request_id, response).await; + if should_refresh_apps_list { + self.refresh_apps_list_after_experimental_feature_enablement_set() + .await; + } + } + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { + let config = match self + .config_api + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => config, + Err(error) => { + tracing::warn!( + "failed to load config for apps list refresh after experimental feature enablement: {}", + error.message + ); + return; + } + }; + if !config.features.apps_enabled(Some(&self.auth_manager)).await { + return; + } + + let outgoing = Arc::clone(&self.outgoing); + tokio::spawn(async move { + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_options( + &config, /*force_refetch*/ true, + ), + ); + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh directory apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + let accessible_connectors = match accessible_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + + let data = connectors::with_app_enabled_state( + connectors::merge_connectors_with_accessible( + all_connectors, + accessible_connectors, + /*all_connectors_loaded*/ true, + ), + &config, + ); + outgoing + .send_server_notification(ServerNotification::AppListUpdated( + AppListUpdatedNotification { data }, + )) + .await; + }); } async fn handle_config_mutation_result( diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index a19cdad857..23ffa80a67 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -27,6 +28,7 @@ use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -1201,6 +1203,108 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu Ok(()) } +#[tokio::test] +async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> { + let initial_connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( + initial_connectors, + Vec::new(), + Duration::ZERO, + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-enable-refresh") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let disable_request = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("apps".to_string(), false)]), + }) + .await?; + let _disable_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(disable_request)), + ) + .await??; + + server_control.set_connectors(vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]); + server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]); + + let enable_request = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("apps".to_string(), true)]), + }) + .await?; + let _enable_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(enable_request)), + ) + .await??; + + let update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!( + update.data, + vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + + server_handle.abort(); + Ok(()) +} + async fn read_app_list_updated_notification( mcp: &mut McpProcess, ) -> Result { diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index abe7277d94..cd409fedd8 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -15,6 +15,7 @@ workspace = true anyhow = { workspace = true } codex-apply-patch = { workspace = true } codex-linux-sandbox = { workspace = true } +codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index e210783a35..efad6c2481 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -4,12 +4,12 @@ use std::path::Path; use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; -const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox"; const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; #[cfg(unix)] @@ -19,6 +19,12 @@ const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Arg0DispatchPaths { + /// Stable path to the current Codex executable for child re-execs. + /// + /// Prefer this over [`std::env::current_exe()`] in code that may run under + /// a test harness, where `current_exe()` can point at the harness binary + /// instead of the real Codex CLI. + pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, } @@ -79,7 +85,7 @@ pub fn arg0_dispatch() -> Option { } } - if exe_name == LINUX_SANDBOX_ARG0 { + if exe_name == CODEX_LINUX_SANDBOX_ARG0 { // Safety: [`run_main`] never returns. codex_linux_sandbox::run_main(); } else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 { @@ -133,8 +139,10 @@ pub fn arg0_dispatch() -> Option { /// /// 1. Load `.env` values from `~/.codex/.env` before creating any threads. /// 2. Construct a Tokio multi-thread runtime. -/// 3. Derive the path to the current executable (so children can re-invoke the -/// sandbox) when running on Linux. +/// 3. Capture the current executable path and derive the +/// `codex-linux-sandbox` helper path (falling back to the current +/// executable if needed) so children can re-invoke the sandbox when running +/// on Linux. /// 4. Execute the provided async `main_fn` inside that runtime, forwarding any /// error. Note that `main_fn` receives [`Arg0DispatchPaths`], which /// contains the helper executable paths needed to construct @@ -150,7 +158,7 @@ where // Retain the TempDir so it exists for the lifetime of the invocation of // this executable. Admittedly, we could invoke `keep()` on it, but it // would be nice to avoid leaving temporary directories behind, if possible. - let path_entry = arg0_dispatch(); + let path_entry_guard = arg0_dispatch(); // Regular invocation – create a Tokio runtime and execute the provided // async entry-point. @@ -158,16 +166,13 @@ where runtime.block_on(async move { let current_exe = std::env::current_exe().ok(); let paths = Arg0DispatchPaths { + codex_self_exe: current_exe.clone(), codex_linux_sandbox_exe: if cfg!(target_os = "linux") { - current_exe.or_else(|| { - path_entry - .as_ref() - .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) - }) + linux_sandbox_exe_path(path_entry_guard.as_ref(), current_exe) } else { None }, - main_execve_wrapper_exe: path_entry + main_execve_wrapper_exe: path_entry_guard .as_ref() .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), }; @@ -176,6 +181,18 @@ where }) } +fn linux_sandbox_exe_path( + path_entry_guard: Option<&Arg0PathEntryGuard>, + current_exe: Option, +) -> Option { + // Prefer the `codex-linux-sandbox` alias when available so callers can + // re-exec through a path whose basename still triggers arg0 dispatch on + // bubblewrap builds that do not support `--argv0`. + path_entry_guard + .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) + .or(current_exe) +} + fn build_runtime() -> anyhow::Result { let mut builder = tokio::runtime::Builder::new_multi_thread(); builder.enable_all(); @@ -276,7 +293,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result std::io::Result std::io::Result> { #[cfg(test)] mod tests { + use super::Arg0DispatchPaths; + use super::Arg0PathEntryGuard; use super::LOCK_FILENAME; use super::janitor_cleanup; + use super::linux_sandbox_exe_path; use std::fs; use std::fs::File; use std::path::Path; + use std::path::PathBuf; + use tempfile::TempDir; fn create_lock(dir: &Path) -> std::io::Result { let lock_path = dir.join(LOCK_FILENAME); @@ -413,6 +436,28 @@ mod tests { .open(lock_path) } + #[test] + fn linux_sandbox_exe_path_prefers_codex_linux_sandbox_alias() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let lock_file = create_lock(temp_dir.path())?; + let alias_path = temp_dir.path().join("codex-linux-sandbox"); + let path_entry = Arg0PathEntryGuard::new( + temp_dir, + lock_file, + Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), + codex_linux_sandbox_exe: Some(alias_path.clone()), + main_execve_wrapper_exe: None, + }, + ); + + assert_eq!( + linux_sandbox_exe_path(Some(&path_entry), Some(PathBuf::from("/usr/bin/codex"))), + Some(alias_path), + ); + Ok(()) + } + #[test] fn janitor_skips_dirs_without_lock_file() -> std::io::Result<()> { let root = tempfile::tempdir()?; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5e0d405eb3..12a531d35d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -530,6 +530,11 @@ struct InteractiveRemoteOptions { /// Accepted forms: `ws://host:port` or `wss://host:port`. #[arg(long = "remote", value_name = "ADDR")] remote: Option, + + /// Name of the environment variable containing the bearer token to send to + /// a remote app server websocket. + #[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")] + remote_auth_token_env: Option, } impl FeatureToggles { @@ -607,6 +612,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let toggle_overrides = feature_toggles.to_overrides()?; root_config_overrides.raw_overrides.extend(toggle_overrides); let root_remote = remote.remote; + let root_remote_auth_token_env = remote.remote_auth_token_env; match subcommand { None => { @@ -614,12 +620,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = - run_interactive_tui(interactive, root_remote.clone(), arg0_paths.clone()).await?; + let exit_info = run_interactive_tui( + interactive, + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "exec")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "exec", + )?; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -627,7 +642,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::Review(review_args)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "review")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "review", + )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; exec_cli.command = Some(ExecCommand::Review(review_args)); prepend_config_flags( @@ -637,11 +656,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::McpServer) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "mcp-server")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp-server", + )?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "mcp")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp", + )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; @@ -653,9 +680,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { analytics_default_enabled, auth, } = app_server_cli; + reject_remote_mode_for_app_server_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + subcommand.as_ref(), + )?; match subcommand { None => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?; let transport = listen; let auth = auth.try_into_settings()?; codex_app_server::run_main_with_transport( @@ -670,10 +701,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } Some(AppServerSubcommand::GenerateTs(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-ts", - )?; let options = codex_app_server_protocol::GenerateTsOptions { experimental_api: gen_cli.experimental, ..Default::default() @@ -685,10 +712,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; } Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-json-schema", - )?; codex_app_server_protocol::generate_json_with_experimental( &gen_cli.out_dir, gen_cli.experimental, @@ -701,7 +724,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } #[cfg(target_os = "macos")] Some(Subcommand::App(app_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "app", + )?; app_cmd::run_app(app_cli).await?; } Some(Subcommand::Resume(ResumeCommand { @@ -724,6 +751,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), arg0_paths.clone(), ) .await?; @@ -747,13 +777,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), arg0_paths.clone(), ) .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "login")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "login", + )?; prepend_config_flags( &mut login_cli.config_overrides, root_config_overrides.clone(), @@ -785,7 +822,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::Logout(mut logout_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "logout")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "logout", + )?; prepend_config_flags( &mut logout_cli.config_overrides, root_config_overrides.clone(), @@ -793,11 +834,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_logout(logout_cli.config_overrides).await; } Some(Subcommand::Completion(completion_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "completion")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "completion", + )?; print_completion(completion_cli); } Some(Subcommand::Cloud(mut cloud_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "cloud")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "cloud", + )?; prepend_config_flags( &mut cloud_cli.config_overrides, root_config_overrides.clone(), @@ -807,7 +856,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { SandboxCommand::Macos(mut seatbelt_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox macos")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox macos", + )?; prepend_config_flags( &mut seatbelt_cli.config_overrides, root_config_overrides.clone(), @@ -819,7 +872,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox linux")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox linux", + )?; prepend_config_flags( &mut landlock_cli.config_overrides, root_config_overrides.clone(), @@ -831,7 +888,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox windows")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox windows", + )?; prepend_config_flags( &mut windows_cli.config_overrides, root_config_overrides.clone(), @@ -845,22 +906,38 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { }, Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::AppServer(cmd) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug app-server")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug app-server", + )?; run_debug_app_server_command(cmd).await?; } DebugSubcommand::ClearMemories => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug clear-memories")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug clear-memories", + )?; run_debug_clear_memories_command(&root_config_overrides, &interactive).await?; } }, Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { ExecpolicySubcommand::Check(cmd) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "execpolicy check")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "execpolicy check", + )?; run_execpolicycheck(cmd)? } }, Some(Subcommand::Apply(mut apply_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "apply")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "apply", + )?; prepend_config_flags( &mut apply_cli.config_overrides, root_config_overrides.clone(), @@ -868,19 +945,31 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_apply_command(apply_cli, /*cwd*/ None).await?; } Some(Subcommand::ResponsesApiProxy(args)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "responses-api-proxy")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "responses-api-proxy", + )?; tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } Some(Subcommand::StdioToUds(cmd)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "stdio-to-uds")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "stdio-to-uds", + )?; let socket_path = cmd.socket_path; tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) .await??; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features list")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features list", + )?; // Respect root-level `-c` overrides plus top-level flags like `--profile`. let mut cli_kv_overrides = root_config_overrides .parse_overrides() @@ -923,11 +1012,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features enable")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features enable", + )?; enable_feature_in_config(&interactive, &feature).await?; } FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features disable")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features disable", + )?; disable_feature_in_config(&interactive, &feature).await?; } }, @@ -1046,18 +1143,64 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } -fn reject_remote_mode_for_subcommand(remote: Option<&str>, subcommand: &str) -> anyhow::Result<()> { +fn reject_remote_mode_for_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: &str, +) -> anyhow::Result<()> { if let Some(remote) = remote { anyhow::bail!( "`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`" ); } + if remote_auth_token_env.is_some() { + anyhow::bail!( + "`--remote-auth-token-env` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } Ok(()) } +fn reject_remote_mode_for_app_server_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + let subcommand_name = match subcommand { + None => "app-server", + Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", + Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", + Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { + "app-server generate-internal-json-schema" + } + }; + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) +} + +fn read_remote_auth_token_from_env_var_with( + env_var_name: &str, + get_var: F, +) -> anyhow::Result +where + F: FnOnce(&str) -> Result, +{ + let auth_token = get_var(env_var_name) + .map_err(|_| anyhow::anyhow!("environment variable `{env_var_name}` is not set"))?; + let auth_token = auth_token.trim().to_string(); + if auth_token.is_empty() { + anyhow::bail!("environment variable `{env_var_name}` is empty"); + } + Ok(auth_token) +} + +fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result { + read_remote_auth_token_from_env_var_with(env_var_name, |name| std::env::var(name)) +} + async fn run_interactive_tui( mut interactive: TuiCli, remote: Option, + remote_auth_token_env: Option, arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { if let Some(prompt) = interactive.prompt.take() { @@ -1089,17 +1232,28 @@ async fn run_interactive_tui( .map(codex_tui_app_server::normalize_remote_addr) .transpose() .map_err(std::io::Error::other)?; + if remote_auth_token_env.is_some() && normalized_remote.is_none() { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires `--remote`.", + )); + } if normalized_remote.is_some() && !use_app_server_tui { return Ok(AppExitInfo::fatal( "`--remote` requires the `tui_app_server` feature flag to be enabled.", )); } if use_app_server_tui { + let remote_auth_token = remote_auth_token_env + .as_deref() + .map(read_remote_auth_token_from_env_var) + .transpose() + .map_err(std::io::Error::other)?; codex_tui_app_server::run_main( into_app_server_tui_cli(interactive), arg0_paths, codex_core::config_loader::LoaderOverrides::default(), normalized_remote, + remote_auth_token, ) .await .map(into_legacy_app_exit_info) @@ -1661,6 +1815,22 @@ mod tests { assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); } + #[test] + fn remote_auth_token_env_flag_parses_for_interactive_root() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote-auth-token-env", + "CODEX_REMOTE_AUTH_TOKEN", + "--remote", + "ws://127.0.0.1:4500", + ]) + .expect("parse"); + assert_eq!( + cli.remote.remote_auth_token_env.as_deref(), + Some("CODEX_REMOTE_AUTH_TOKEN") + ); + } + #[test] fn remote_flag_parses_for_resume_subcommand() { let cli = @@ -1676,7 +1846,7 @@ mod tests { #[test] fn reject_remote_mode_for_non_interactive_subcommands() { - let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), "exec") + let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), None, "exec") .expect_err("non-interactive subcommands should reject --remote"); assert!( err.to_string() @@ -1684,6 +1854,59 @@ mod tests { ); } + #[test] + fn reject_remote_auth_token_env_for_non_interactive_subcommands() { + let err = reject_remote_mode_for_subcommand(None, Some("CODEX_REMOTE_AUTH_TOKEN"), "exec") + .expect_err("non-interactive subcommands should reject --remote-auth-token-env"); + assert!( + err.to_string() + .contains("only supported for interactive TUI commands") + ); + } + + #[test] + fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() { + let subcommand = + AppServerSubcommand::GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand { + out_dir: PathBuf::from("/tmp/out"), + }); + let err = reject_remote_mode_for_app_server_subcommand( + None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env"); + assert!(err.to_string().contains("generate-internal-json-schema")); + } + + #[test] + fn read_remote_auth_token_from_env_var_reports_missing_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Err(std::env::VarError::NotPresent) + }) + .expect_err("missing env vars should be rejected"); + assert!(err.to_string().contains("is not set")); + } + + #[test] + fn read_remote_auth_token_from_env_var_trims_values() { + let auth_token = + read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" bearer-token ".to_string()) + }) + .expect("env var should parse"); + assert_eq!(auth_token, "bearer-token"); + } + + #[test] + fn read_remote_auth_token_from_env_var_rejects_empty_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" \n\t ".to_string()) + }) + .expect_err("empty env vars should be rejected"); + assert!(err.to_string().contains("is empty")); + } + #[test] fn app_server_listen_websocket_url_parses() { let app_server = app_server_from_args( diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 30a911cb63..52707f1da9 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -306,6 +306,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }; servers.insert(name.clone(), new_entry); diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index e8773b42fd..591eb1f28a 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -32,9 +32,8 @@ codex_rust_crate( "//codex-rs:node-version.txt", ], rustc_env = { - # Askama resolves template paths relative to CARGO_MANIFEST_DIR. In - # Bazel, the Cargo-provided absolute source path points outside the - # sandbox, so keep the manifest root anchored inside the execroot. + # Keep manifest-root path lookups inside the Bazel execroot for code + # that relies on env!("CARGO_MANIFEST_DIR"). "CARGO_MANIFEST_DIR": "codex-rs/core", }, integration_compile_data_extra = [ diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 25e44d7631..9497da9ebd 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -21,7 +21,6 @@ anyhow = { workspace = true } arc-swap = "1.8.2" async-channel = { workspace = true } async-trait = { workspace = true } -askama = { workspace = true } base64 = { workspace = true } bm25 = { workspace = true } chardetng = { workspace = true } @@ -46,7 +45,6 @@ codex-hooks = { workspace = true } codex-instructions = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } -codex-artifacts = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } @@ -66,6 +64,7 @@ codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-utils-stream-parser = { workspace = true } +codex-utils-template = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } csv = { workspace = true } dirs = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c154ef463d..4b8cb793b7 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -59,10 +59,11 @@ only when the split filesystem policy round-trips through the legacy cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the more specific writable child must reopen under a denied parent. -The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and -supports the required argv-rewrite flags, and falls back to the vendored -bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is -missing or too old to support the required flags, Codex also surfaces a startup +The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available. If +`/usr/bin/bwrap` is present but too old to support `--argv0`, the helper keeps +using system bubblewrap and switches to a no-`--argv0` compatibility path for +the inner re-exec. If `/usr/bin/bwrap` is missing, it falls back to the +vendored bubblewrap path compiled into the binary and Codex surfaces a startup warning through its normal notification path instead of printing directly from the sandbox helper. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index adaf1f92d6..ee33564472 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -338,9 +338,6 @@ "apps": { "type": "boolean" }, - "artifact": { - "type": "boolean" - }, "child_agents_md": { "type": "boolean" }, @@ -748,6 +745,21 @@ } ] }, + "McpServerToolConfig": { + "additionalProperties": false, + "description": "Per-tool approval settings for a single MCP server tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + } + }, + "type": "object" + }, "MemoriesToml": { "additionalProperties": false, "description": "Memories settings loaded from config.toml.", @@ -1249,7 +1261,9 @@ "type": "object" }, "RawMcpServerConfig": { - "additionalProperties": false, + "additionalProperties": { + "$ref": "#/definitions/McpServerToolConfig" + }, "properties": { "args": { "default": null, @@ -1316,6 +1330,11 @@ }, "type": "object" }, + "name": { + "default": null, + "description": "Legacy display-name field accepted for backward compatibility.", + "type": "string" + }, "oauth_resource": { "default": null, "type": "string" @@ -1347,6 +1366,13 @@ "format": "double", "type": "number" }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/McpServerToolConfig" + }, + "default": null, + "type": "object" + }, "url": { "type": "string" } @@ -1954,9 +1980,6 @@ "apps": { "type": "boolean" }, - "artifact": { - "type": "boolean" - }, "child_agents_md": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a4bdb2c944..f29d37fe25 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -867,6 +867,7 @@ pub(crate) struct TurnContext { pub(crate) features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, + pub(crate) codex_self_exe: Option, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, pub(crate) truncation_policy: TruncationPolicy, @@ -974,6 +975,7 @@ impl TurnContext { features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), + codex_self_exe: self.codex_self_exe.clone(), codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy, @@ -1431,6 +1433,7 @@ impl Session { features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, + codex_self_exe: per_turn_config.codex_self_exe.clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy: model_info.truncation_policy.into(), @@ -5477,6 +5480,7 @@ async fn spawn_review_thread( shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, + codex_self_exe: parent_turn_context.codex_self_exe.clone(), codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), js_repl: Arc::clone(&sess.js_repl), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 34c41d44b0..7d2f6c573a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,10 +1,12 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; +use crate::config::types::AppToolApproval; use crate::config::types::ApprovalsReviewer; use crate::config::types::BundledSkillsConfig; use crate::config::types::FeedbackConfigToml; use crate::config::types::HistoryPersistence; +use crate::config::types::McpServerToolConfig; use crate::config::types::McpServerTransportConfig; use crate::config::types::MemoriesConfig; use crate::config::types::MemoriesToml; @@ -57,6 +59,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), } } @@ -77,6 +80,7 @@ fn http_mcp(url: &str) -> McpServerConfig { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), } } @@ -1853,6 +1857,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); @@ -1958,6 +1963,85 @@ startup_timeout_ms = 2500 Ok(()) } +#[test] +fn mcp_servers_toml_parses_per_tool_approval_overrides() { + let config = toml::from_str::( + r#" +[mcp_servers.docs] +command = "docs-server" +name = "Docs" + +[mcp_servers.docs.tools.search] +approval_mode = "approve" +"#, + ) + .expect("TOML deserialization should succeed"); + let tool = config + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); +} + +#[test] +fn mcp_servers_toml_parses_legacy_flattened_per_tool_approval_overrides() { + let config = toml::from_str::( + r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.search] +approval_mode = "approve" +"#, + ) + .expect("legacy TOML deserialization should succeed"); + let tool = config + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); +} + +#[test] +fn mcp_servers_toml_parses_tool_approval_override_for_reserved_name() { + let config = toml::from_str::( + r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.tools.command] +approval_mode = "approve" +"#, + ) + .expect("TOML deserialization should succeed"); + let tool = config + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("command")) + .expect("docs/command tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); +} + #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -2009,6 +2093,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2081,6 +2166,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2133,6 +2219,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2183,6 +2270,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2249,6 +2337,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( @@ -2327,6 +2416,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2358,6 +2448,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); apply_blocking( @@ -2424,6 +2515,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ), ( @@ -2445,6 +2537,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ), ]); @@ -2529,6 +2622,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2575,6 +2669,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2621,6 +2716,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { disabled_tools: Some(vec!["blocked".to_string()]), scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -2671,6 +2767,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh disabled_tools: None, scopes: None, oauth_resource: Some("https://resource.example.com".to_string()), + tools: HashMap::new(), }, )]); @@ -4320,6 +4417,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4462,6 +4560,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4602,6 +4701,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4728,6 +4828,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -5611,7 +5712,7 @@ fn system_bwrap_warning_reports_missing_system_bwrap() { #[cfg(target_os = "linux")] #[test] -fn system_bwrap_warning_reports_too_old_system_bwrap() { +fn system_bwrap_warning_skips_too_old_system_bwrap() { let fake_bwrap = write_fake_bwrap( r#"#!/bin/sh if [ "$1" = "--help" ]; then @@ -5622,27 +5723,12 @@ exit 1 "#, ); let fake_bwrap_path: &Path = fake_bwrap.as_ref(); - let warning = system_bwrap_warning_for_path(fake_bwrap_path) - .expect("old system bwrap should emit a warning"); - assert!(warning.contains("too old to support `--argv0`")); -} - -#[cfg(target_os = "linux")] -#[test] -fn system_bwrap_warning_skips_supported_system_bwrap() { - let fake_bwrap = write_fake_bwrap( - r#"#!/bin/sh -if [ "$1" = "--help" ]; then - echo ' --argv0 PROGRAM' - exit 0 -fi -exit 1 -"#, + assert_eq!( + system_bwrap_warning_for_path(fake_bwrap_path), + None, + "Do not warn even if bwrap does not support `--argv0`", ); - let fake_bwrap_path: &Path = fake_bwrap.as_ref(); - - assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None); } #[cfg(not(target_os = "linux"))] diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 3f7e3b1170..370c46ce4c 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -125,7 +125,9 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> // TODO(jif) move to a dedicated file mod document_helpers { + use crate::config::types::AppToolApproval; use crate::config::types::McpServerConfig; + use crate::config::types::McpServerToolConfig; use crate::config::types::McpServerTransportConfig; use toml_edit::Array as TomlArray; use toml_edit::InlineTable; @@ -248,10 +250,32 @@ mod document_helpers { { entry["oauth_resource"] = value(resource.clone()); } + if !config.tools.is_empty() { + let mut tools = new_implicit_table(); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (name, tool_config) in tool_entries { + tools.insert(name, serialize_mcp_server_tool(tool_config)); + } + entry.insert("tools", TomlItem::Table(tools)); + } entry } + fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + if let Some(approval_mode) = config.approval_mode { + entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + TomlItem::Table(entry) + } + pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { TomlItem::Table(serialize_mcp_server_table(config)) } diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 632716f00d..d7386c546c 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1,4 +1,6 @@ use super::*; +use crate::config::types::AppToolApproval; +use crate::config::types::McpServerToolConfig; use crate::config::types::McpServerTransportConfig; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -582,6 +584,7 @@ fn blocking_replace_mcp_servers_round_trips() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); @@ -607,6 +610,7 @@ fn blocking_replace_mcp_servers_round_trips() { disabled_tools: Some(vec!["forbidden".to_string()]), scopes: None, oauth_resource: Some("https://resource.example.com".to_string()), + tools: HashMap::new(), }, ); @@ -643,6 +647,53 @@ B = \"2\" assert_eq!(raw, expected); } +#[test] +fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + let mut servers = BTreeMap::new(); + servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::from([( + "search".to_string(), + McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }, + ); + + apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = "\ +[mcp_servers.docs] +command = \"docs-server\" + +[mcp_servers.docs.tools.search] +approval_mode = \"approve\" +"; + assert_eq!(raw, expected); +} + #[test] fn blocking_replace_mcp_servers_preserves_inline_comments() { let tmp = tempdir().expect("tmpdir"); @@ -676,6 +727,7 @@ foo = { command = "cmd" } disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); @@ -721,6 +773,7 @@ foo = { command = "cmd" } # keep me disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); @@ -765,6 +818,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); @@ -810,6 +864,7 @@ foo = { command = "cmd" } disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 761e43b936..b2b55feeca 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -96,8 +96,6 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; -#[cfg(target_os = "linux")] -use std::process::Command; use crate::config::permissions::compile_permission_profile; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; @@ -172,27 +170,8 @@ fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option { system_bwrap_path.display() )); } - if system_bwrap_supports_argv0(system_bwrap_path) { - return None; - } - Some(format!( - "Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", - system_bwrap_path.display() - )) -} - -#[cfg(target_os = "linux")] -fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { - // bubblewrap added `--argv0` in v0.9.0: - // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 - let output = match Command::new(system_bwrap_path).arg("--help").output() { - Ok(output) => output, - Err(_) => return false, - }; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - stdout.contains("--argv0") || stderr.contains("--argv0") + None } fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { @@ -476,6 +455,10 @@ pub struct Config { /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, + /// Path to the current Codex executable. This cannot be set in the config + /// file: it must be set in code via [`ConfigOverrides`]. + pub codex_self_exe: Option, + /// Path to the `codex-linux-sandbox` executable. This must be set if /// [`codex_sandboxing::SandboxType::LinuxSeccomp`] is used. Note that this /// cannot be set in the config file: it must be set in code via @@ -760,7 +743,7 @@ impl Config { /// designed to use [AskForApproval::Never] exclusively. /// /// Further, [ConfigOverrides] contains some options that are not supported - /// in [ConfigToml], such as `cwd`, `codex_linux_sandbox_exe`, and + /// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and /// `main_execve_wrapper_exe`. pub async fn load_with_cli_overrides_and_harness_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1838,6 +1821,7 @@ pub struct ConfigOverrides { pub model_provider: Option, pub service_tier: Option>, pub config_profile: Option, + pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, pub js_repl_node_path: Option, @@ -2036,6 +2020,7 @@ impl Config { model_provider, service_tier: service_tier_override, config_profile: config_profile_key, + codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, js_repl_node_path: js_repl_node_path_override, @@ -2642,6 +2627,7 @@ impl Config { history, ephemeral: ephemeral.unwrap_or_default(), file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), + codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, js_repl_node_path, diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs index 102b7da514..53e0f7f028 100644 --- a/codex-rs/core/src/config/schema.rs +++ b/codex-rs/core/src/config/schema.rs @@ -22,6 +22,9 @@ pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { let mut validation = ObjectValidation::default(); for feature in FEATURES { + if feature.id == codex_features::Feature::Artifact { + continue; + } validation .properties .insert(feature.key.to_string(), schema_gen.subschema_for::()); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index c9f83482aa..a69fec3431 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -108,6 +108,10 @@ pub struct McpServerConfig { /// Optional OAuth resource parameter to include during MCP login (RFC 8707). #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth_resource: Option, + + /// Per-tool approval settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, } // Raw MCP config shape used for deserialization and JSON Schema generation. @@ -154,6 +158,15 @@ pub(crate) struct RawMcpServerConfig { pub scopes: Option>, #[serde(default)] pub oauth_resource: Option, + /// Legacy display-name field accepted for backward compatibility. + #[serde(default, rename = "name")] + pub _name: Option, + #[serde(default)] + pub tools: Option>, + /// Legacy flattened per-tool approval settings accepted for backward compatibility. + #[serde(default)] + #[serde(flatten)] + pub legacy_tools: HashMap, } impl<'de> Deserialize<'de> for McpServerConfig { @@ -178,6 +191,10 @@ impl<'de> Deserialize<'de> for McpServerConfig { let disabled_tools = raw.disabled_tools.clone(); let scopes = raw.scopes.clone(); let oauth_resource = raw.oauth_resource.clone(); + let mut tools = raw.legacy_tools.clone(); + if let Some(nested_tools) = raw.tools.clone() { + tools.extend(nested_tools); + } fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> where @@ -236,6 +253,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { disabled_tools, scopes, oauth_resource, + tools, }) } } @@ -496,6 +514,15 @@ pub enum AppToolApproval { Approve, } +/// Per-tool approval settings for a single MCP server tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct McpServerToolConfig { + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + /// Default settings that apply to all apps. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 744db4886b..ece93b1da1 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PostToolUseOutcome; +use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; @@ -15,6 +17,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookRunSummary; use codex_protocol::user_input::UserInput; +use serde_json::Value; use crate::codex::Session; use crate::codex::TurnContext; @@ -141,6 +144,33 @@ pub(crate) async fn run_pre_tool_use_hooks( if should_block { block_reason } else { None } } +pub(crate) async fn run_post_tool_use_hooks( + sess: &Arc, + turn_context: &Arc, + tool_use_id: String, + command: String, + tool_response: Value, +) -> PostToolUseOutcome { + let request = PostToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.to_path_buf(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name: "Bash".to_string(), + tool_use_id, + command, + tool_response, + }; + let preview_runs = sess.hooks().preview_post_tool_use(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let outcome = sess.hooks().run_post_tool_use(request).await; + emit_hook_completed_events(sess, turn_context, outcome.hook_events.clone()).await; + outcome +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index c1dc4ba542..ef55a207e2 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -5,6 +5,7 @@ use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; use std::collections::HashMap; @@ -48,11 +49,24 @@ where use_legacy_landlock, allow_network_for_proxy(/*enforce_managed_network*/ false), ); - let arg0 = Some("codex-linux-sandbox"); + let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref(); + // Preserve the helper alias when we already have it; otherwise force argv0 + // so arg0 dispatch still reaches the Linux sandbox path. + let arg0 = if codex_linux_sandbox_exe + .file_name() + .and_then(|name| name.to_str()) + == Some(CODEX_LINUX_SANDBOX_ARG0) + { + // Old bubblewrap builds without `--argv0` need a real helper path whose + // basename still dispatches to the Linux sandbox entrypoint. + codex_linux_sandbox_exe.to_string_lossy().into_owned() + } else { + CODEX_LINUX_SANDBOX_ARG0.to_string() + }; spawn_child_async(SpawnChildRequest { - program: codex_linux_sandbox_exe.as_ref().to_path_buf(), + program: codex_linux_sandbox_exe.to_path_buf(), args, - arg0, + arg0: Some(&arg0), cwd: command_cwd, network_sandbox_policy, network, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 446a1a27e5..1ed3250f3d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -53,7 +53,6 @@ pub mod models_manager; mod network_policy_decision; pub mod network_proxy_loader; mod original_image_detail; -mod packages; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 81ee0c7fe8..a9d2388f70 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -176,6 +176,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), } } diff --git a/codex-rs/core/src/mcp/mod_tests.rs b/codex-rs/core/src/mcp/mod_tests.rs index dc9465e103..855e71e1f2 100644 --- a/codex-rs/core/src/mcp/mod_tests.rs +++ b/codex-rs/core/src/mcp/mod_tests.rs @@ -235,6 +235,7 @@ async fn effective_mcp_servers_include_plugins_without_overriding_user_config() disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); config diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index 489dc2e438..ba4ef26488 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -428,6 +428,7 @@ fn mcp_dependency_to_server_config( disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }); } @@ -453,6 +454,7 @@ fn mcp_dependency_to_server_config( disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }); } diff --git a/codex-rs/core/src/mcp/skill_dependencies_tests.rs b/codex-rs/core/src/mcp/skill_dependencies_tests.rs index ebabe16f80..34c6a767f5 100644 --- a/codex-rs/core/src/mcp/skill_dependencies_tests.rs +++ b/codex-rs/core/src/mcp/skill_dependencies_tests.rs @@ -48,6 +48,7 @@ fn collect_missing_respects_canonical_installed_key() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -97,6 +98,7 @@ fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); diff --git a/codex-rs/core/src/mcp_connection_manager_tests.rs b/codex-rs/core/src/mcp_connection_manager_tests.rs index c5f7fc4a40..2331d0c1fe 100644 --- a/codex-rs/core/src/mcp_connection_manager_tests.rs +++ b/codex-rs/core/src/mcp_connection_manager_tests.rs @@ -542,6 +542,7 @@ fn mcp_init_error_display_prompts_for_github_pat() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, auth_status: McpAuthStatus::Unsupported, }; @@ -590,6 +591,7 @@ fn mcp_init_error_display_reports_generic_errors() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, auth_status: McpAuthStatus::Unsupported, }; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 04341bdad8..9855481b01 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,7 +1,10 @@ use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::McpElicitationObjectType; use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationRequest; @@ -12,8 +15,10 @@ use crate::arc_monitor::ArcMonitorOutcome; use crate::arc_monitor::monitor_action; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::Config; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; +use crate::config::load_global_mcp_servers; use crate::config::types::AppToolApproval; use crate::connectors; use crate::guardian::GuardianApprovalRequest; @@ -46,6 +51,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use rmcp::model::ToolAnnotations; +use serde::Deserialize; use serde::Serialize; use std::path::Path; use std::sync::Arc; @@ -104,6 +110,11 @@ pub(crate) async fn handle_mcp_tool_call( } else { connectors::AppToolPolicy::default() }; + let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME { + app_tool_policy.approval + } else { + custom_mcp_tool_approval_mode(turn_context.as_ref(), &server, &tool_name) + }; if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { let result = notify_mcp_tool_call_skip( @@ -151,7 +162,7 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, &invocation, metadata.as_ref(), - app_tool_policy.approval, + approval_mode, ) .await { @@ -491,6 +502,27 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +fn custom_mcp_tool_approval_mode( + turn_context: &TurnContext, + server: &str, + tool_name: &str, +) -> AppToolApproval { + turn_context + .config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("mcp_servers")) + .cloned() + .and_then(|value| { + HashMap::::deserialize(value).ok() + }) + .and_then(|servers| servers.get(server).cloned()) + .and_then(|server| server.tools.get(tool_name).cloned()) + .and_then(|tool| tool.approval_mode) + .unwrap_or_default() +} + fn build_mcp_tool_call_request_meta( turn_context: &TurnContext, server: &str, @@ -560,7 +592,6 @@ const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default"; const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow"; -const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS: &str = "mcp_tool_call__full_access"; pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool { question_id @@ -595,11 +626,14 @@ async fn maybe_request_mcp_tool_approval( metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { + if is_full_access_mode(turn_context) { + return None; + } + let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); let approval_required = requires_mcp_tool_approval(annotations); let mut monitor_reason = None; - let auto_approved_by_policy = approval_mode == AppToolApproval::Approve - || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); + let auto_approved_by_policy = approval_mode == AppToolApproval::Approve; if auto_approved_by_policy { if !approval_required { @@ -812,12 +846,7 @@ fn persistent_mcp_tool_approval_key( metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { - if invocation.server != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - session_mcp_tool_approval_key(invocation, metadata, approval_mode) - .filter(|key| key.connector_id.is_some()) } pub(crate) fn build_guardian_mcp_tool_review_request( @@ -865,16 +894,12 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool { fn mcp_tool_approval_callsite_mode( approval_mode: AppToolApproval, - turn_context: &TurnContext, + _turn_context: &TurnContext, ) -> &'static str { match approval_mode { AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW, AppToolApproval::Auto | AppToolApproval::Prompt => { - if approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context) { - MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS - } else { - MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT - } + MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT } } } @@ -1356,21 +1381,25 @@ async fn maybe_persist_mcp_tool_approval( turn_context: &TurnContext, key: McpToolApprovalKey, ) { - let Some(connector_id) = key.connector_id.clone() else { - remember_mcp_tool_approval(sess, key).await; - return; - }; let tool_name = key.tool_name.clone(); - if let Err(err) = + let persist_result = if key.server == CODEX_APPS_MCP_SERVER_NAME { + let Some(connector_id) = key.connector_id.clone() else { + remember_mcp_tool_approval(sess, key).await; + return; + }; persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name) .await - { + } else { + persist_custom_mcp_tool_approval(&turn_context.config, &key.server, &tool_name).await + }; + + if let Err(err) = persist_result { error!( error = %err, - connector_id, + server = key.server, tool_name, - "failed to persist codex app tool approval" + "failed to persist MCP tool approval" ); remember_mcp_tool_approval(sess, key).await; return; @@ -1400,6 +1429,67 @@ async fn persist_codex_app_tool_approval( .await } +async fn persist_custom_mcp_tool_approval( + config: &Config, + server: &str, + tool_name: &str, +) -> anyhow::Result<()> { + let config_folder = if let Some(project_config_folder) = + project_mcp_tool_approval_config_folder(config, server) + { + project_config_folder + } else { + let servers = load_global_mcp_servers(&config.codex_home).await?; + if !servers.contains_key(server) { + anyhow::bail!("MCP server `{server}` is not configured in config.toml"); + } + config.codex_home.clone() + }; + + ConfigEditsBuilder::new(&config_folder) + .with_edits([ConfigEdit::SetPath { + segments: vec![ + "mcp_servers".to_string(), + server.to_string(), + "tools".to_string(), + tool_name.to_string(), + "approval_mode".to_string(), + ], + value: value("approve"), + }]) + .apply() + .await +} + +fn project_mcp_tool_approval_config_folder(config: &Config, server: &str) -> Option { + config + .config_layer_stack + .layers_high_to_low() + .into_iter() + .find_map(|layer| { + if !matches!(layer.name, ConfigLayerSource::Project { .. }) { + return None; + } + + let servers = layer + .config + .as_table() + .and_then(|table| table.get("mcp_servers")) + .cloned() + .and_then(|value| { + HashMap::::deserialize(value) + .ok() + })?; + if servers.contains_key(server) { + layer + .config_folder() + .map(|folder| folder.as_path().to_path_buf()) + } else { + None + } + }) +} + fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint); if destructive_hint == Some(true) { diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index edd698c134..cf3c761faf 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1,11 +1,14 @@ use super::*; use crate::codex::make_session_and_context; use crate::config::ApprovalsReviewer; +use crate::config::ConfigBuilder; use crate::config::ConfigToml; use crate::config::types::AppConfig; use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsConfigToml; +use crate::config::types::McpServerConfig; +use crate::config::types::McpServerToolConfig; use codex_config::CONFIG_TOML_FILE; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -378,13 +381,13 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() { } #[test] -fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { +fn custom_mcp_tool_question_offers_session_remember_and_always_allow() { let question = build_mcp_tool_approval_question( "q".to_string(), "custom_server", "run_action", None, - prompt_options(true, false), + prompt_options(true, true), None, ); @@ -398,13 +401,14 @@ fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { vec![ MCP_TOOL_APPROVAL_ACCEPT.to_string(), MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), + MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(), MCP_TOOL_APPROVAL_CANCEL.to_string(), ] ); } #[test] -fn custom_servers_keep_session_remember_without_persistent_approval() { +fn custom_servers_support_session_and_persistent_approval() { let invocation = McpInvocation { server: "custom_server".to_string(), tool: "run_action".to_string(), @@ -418,11 +422,11 @@ fn custom_servers_keep_session_remember_without_persistent_approval() { assert_eq!( session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), - Some(expected) + Some(expected.clone()) ); assert_eq!( persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), - None + Some(expected) ); } @@ -612,7 +616,7 @@ fn approval_elicitation_meta_marks_tool_approvals() { } #[test] -fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() { +fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_servers() { assert_eq!( build_mcp_tool_approval_elicitation_meta( "custom_server", @@ -625,11 +629,14 @@ fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() )), Some(&serde_json::json!({"id": 1})), None, - prompt_options(true, false), + prompt_options(true, true), ), Some(serde_json::json!({ MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, - MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_KEY: [ + MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_ALWAYS, + ], MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { @@ -843,8 +850,8 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so } #[tokio::test] -async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_access() { - let (_session, mut turn_context) = make_session_and_context().await; +async fn approval_callsite_mode_distinguishes_default_and_always_allow() { + let (_session, turn_context) = make_session_and_context().await; assert_eq!( mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), @@ -858,20 +865,6 @@ async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_acce mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context), "mcp_tool_call__always_allow" ); - - turn_context - .approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - turn_context - .sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("test setup should allow updating sandbox policy"); - - assert_eq!( - mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), - "mcp_tool_call__full_access" - ); } #[test] @@ -992,6 +985,41 @@ async fn persist_codex_app_tool_approval_writes_tool_override() { assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]")); } +#[tokio::test] +async fn persist_custom_mcp_tool_approval_writes_tool_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + ) + .expect("seed config"); + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("load config"); + + persist_custom_mcp_tool_approval(&config, "docs", "search") + .await + .expect("persist approval"); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + let tool = parsed + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert!(contents.contains("[mcp_servers.docs.tools.search]")); +} + #[tokio::test] async fn maybe_persist_mcp_tool_approval_reloads_session_config() { let (session, turn_context) = make_session_and_context().await; @@ -1031,6 +1059,104 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config() { assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); } +#[tokio::test] +async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_server() { + let (session, turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + ) + .expect("seed config"); + let key = McpToolApprovalKey { + server: "docs".to_string(), + connector_id: None, + tool_name: "search".to_string(), + }; + + maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; + + let config = session.get_config().await; + let mcp_servers_toml = config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("mcp_servers")) + .cloned() + .expect("mcp_servers table"); + let mcp_servers = HashMap::::deserialize(mcp_servers_toml) + .expect("deserialize MCP servers"); + let tool = mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); +} + +#[tokio::test] +async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() { + let (session, mut turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + let project_dir = tempdir().expect("tempdir"); + std::fs::write(project_dir.path().join(".git"), "gitdir: nowhere").expect("seed git marker"); + let project_codex_dir = project_dir.path().join(".codex"); + std::fs::create_dir_all(&project_codex_dir).expect("create project .codex dir"); + std::fs::write( + project_codex_dir.join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + ) + .expect("seed project config"); + ConfigEditsBuilder::new(&codex_home) + .set_project_trust_level( + project_dir.path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .apply() + .await + .expect("trust project"); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(project_dir.path().to_path_buf())) + .build() + .await + .expect("load project config"); + turn_context.cwd = config.cwd.clone(); + turn_context.config = Arc::new(config); + let key = McpToolApprovalKey { + server: "docs".to_string(), + connector_id: None, + tool_name: "search".to_string(), + }; + + maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; + + let contents = std::fs::read_to_string(project_codex_dir.join(CONFIG_TOML_FILE)) + .expect("read project config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse project config"); + let tool = parsed + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert!(contents.contains("[mcp_servers.docs.tools.search]")); + assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); +} + #[tokio::test] async fn approve_mode_skips_when_annotations_do_not_require_approval() { let (session, turn_context) = make_session_and_context().await; @@ -1133,6 +1259,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: "docs".to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations(Some(false), Some(true), Some(true))), + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-2-custom", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { use wiremock::Mock; @@ -1203,7 +1398,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { } #[tokio::test] -async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { +async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -1224,7 +1419,7 @@ async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { "why": "high-risk action", }], }))) - .expect(1) + .expect(0) .mount(&server) .await; @@ -1261,22 +1456,23 @@ async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { codex_apps_meta: None, }; - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-2", - &invocation, - Some(&metadata), + for approval_mode in [ AppToolApproval::Auto, - ) - .await; + AppToolApproval::Prompt, + AppToolApproval::Approve, + ] { + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-2", + &invocation, + Some(&metadata), + approval_mode, + ) + .await; - assert_eq!( - decision, - Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - "Tool call was cancelled because of safety risks: high-risk action".to_string(), - )) - ); + assert_eq!(decision, None); + } } #[tokio::test] diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index 851179426f..56cb2b7b60 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -1,37 +1,42 @@ use crate::memories::memory_root; use crate::memories::phase_one; use crate::memories::storage::rollout_summary_file_stem_from_parts; -use askama::Template; use codex_protocol::openai_models::ModelInfo; use codex_state::Phase2InputSelection; use codex_state::Stage1Output; use codex_state::Stage1OutputRef; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; +use codex_utils_template::Template; use std::path::Path; +use std::sync::LazyLock; use tokio::fs; use tracing::warn; -#[derive(Template)] -#[template(path = "memories/consolidation.md", escape = "none")] -struct ConsolidationPromptTemplate<'a> { - memory_root: &'a str, - phase2_input_selection: &'a str, -} +static CONSOLIDATION_PROMPT_TEMPLATE: LazyLock