Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare

# Conflicts:
#	codex-rs/core/tests/suite/tools.rs
This commit is contained in:
Matthew Zeng
2026-05-13 11:03:45 -07:00
433 changed files with 24393 additions and 12313 deletions
+1
View File
@@ -1,5 +1,6 @@
# Core crate ownership.
/codex-rs/core/ @openai/codex-core-agent-team
/codex-rs/ext/extension-api/ @openai/codex-core-agent-team
# Keep ownership changes reviewed by the same team.
/.github/CODEOWNERS @openai/codex-core-agent-team
+1 -1
View File
@@ -887,7 +887,6 @@
"enum-as-inner_0.6.1": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}",
"enumflags2_0.7.12": "{\"dependencies\":[{\"name\":\"enumflags2_derive\",\"req\":\"=0.7.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.0\"}],\"features\":{\"std\":[]}}",
"enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}",
"env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}",
"env_filter_1.0.0": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}",
"env_filter_1.0.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.12.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}",
"env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}",
@@ -1483,6 +1482,7 @@
"serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}",
"serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}",
"serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}",
"serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}",
"serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}",
"serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}",
"serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}",
+53 -22
View File
@@ -1922,7 +1922,6 @@ dependencies = [
"codex-state",
"codex-thread-store",
"codex-tools",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-cli",
@@ -1931,13 +1930,16 @@ dependencies = [
"core_test_support",
"flate2",
"futures",
"hmac",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"reqwest",
"rmcp",
"serde",
"serde_json",
"serial_test",
"sha2",
"shlex",
"tar",
"tempfile",
@@ -1968,6 +1970,8 @@ dependencies = [
"codex-exec-server",
"codex-feedback",
"codex-protocol",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
"futures",
"pretty_assertions",
@@ -2054,8 +2058,11 @@ dependencies = [
name = "codex-app-server-transport"
version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"clap",
"codex-api",
"codex-app-server-protocol",
"codex-config",
@@ -2066,12 +2073,18 @@ dependencies = [
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"futures",
"gethostname",
"hmac",
"jsonwebtoken",
"owo-colors",
"pretty_assertions",
"serde",
"serde_json",
"sha2",
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"tokio-util",
@@ -2419,6 +2432,7 @@ dependencies = [
"prost 0.14.3",
"schemars 0.8.22",
"serde",
"serde_ignored",
"serde_json",
"serde_path_to_error",
"sha2",
@@ -2505,7 +2519,6 @@ dependencies = [
"codex-terminal-detection",
"codex-test-binary-support",
"codex-thread-store",
"codex-tool-api",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-cache",
@@ -2516,7 +2529,6 @@ dependencies = [
"codex-utils-path",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-stream-parser",
"codex-utils-string",
"codex-utils-template",
@@ -2526,7 +2538,6 @@ dependencies = [
"ctor 0.6.3",
"dirs",
"dunce",
"env-flags",
"eventsource-stream",
"futures",
"http 1.4.0",
@@ -2741,6 +2752,7 @@ dependencies = [
"ctor 0.6.3",
"futures",
"pretty_assertions",
"prost 0.14.3",
"reqwest",
"serde",
"serde_json",
@@ -2808,7 +2820,7 @@ name = "codex-extension-api"
version = "0.0.0"
dependencies = [
"codex-protocol",
"codex-tool-api",
"codex-tools",
]
[[package]]
@@ -2907,7 +2919,6 @@ dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
"codex-protocol",
"pretty_assertions",
]
@@ -3128,6 +3139,26 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-memories-extension"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
"codex-memories-read",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "codex-memories-mcp"
version = "0.0.0"
@@ -3688,6 +3719,7 @@ dependencies = [
"codex-protocol",
"codex-rollout",
"codex-state",
"codex-utils-path",
"pretty_assertions",
"serde",
"serde_json",
@@ -3698,15 +3730,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-tool-api"
version = "0.0.0"
dependencies = [
"pretty_assertions",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "codex-tools"
version = "0.0.0"
@@ -3717,10 +3740,12 @@ dependencies = [
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-pty",
"codex-utils-string",
"pretty_assertions",
"rmcp",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
]
@@ -3780,6 +3805,7 @@ dependencies = [
"codex-utils-string",
"codex-windows-sandbox",
"color-eyre",
"core_test_support",
"cpal",
"crossterm",
"derive_more 2.1.1",
@@ -3830,6 +3856,7 @@ dependencies = [
"which 8.0.0",
"windows-sys 0.52.0",
"winsplit",
"wiremock",
]
[[package]]
@@ -5378,12 +5405,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "env-flags"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415"
[[package]]
name = "env_filter"
version = "1.0.0"
@@ -5435,7 +5456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11622,6 +11643,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
+5 -4
View File
@@ -47,6 +47,7 @@ members = [
"ext/extension-api",
"ext/guardian",
"ext/git-attribution",
"ext/memories",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -108,7 +109,6 @@ members = [
"test-binary-support",
"thread-manager-sample",
"thread-store",
"tool-api",
"uds",
"codex-experimental-api-macros",
"plugin",
@@ -180,6 +180,7 @@ codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-message-history = { path = "message-history" }
codex-memories-extension = { path = "ext/memories" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
@@ -209,7 +210,6 @@ codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-terminal-detection = { path = "terminal-detection" }
codex-test-binary-support = { path = "test-binary-support" }
codex-thread-store = { path = "thread-store" }
codex-tool-api = { path = "tool-api" }
codex-tools = { path = "tools" }
codex-tui = { path = "tui" }
codex-uds = { path = "uds" }
@@ -228,7 +228,6 @@ codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
codex-utils-plugins = { path = "utils/plugins" }
codex-utils-pty = { path = "utils/pty" }
codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
@@ -266,6 +265,7 @@ chrono = "0.4.43"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
crossbeam-channel = "0.5.15"
crypto_box = { version = "0.9.1", features = ["seal"] }
crossterm = "0.28.1"
@@ -280,7 +280,6 @@ dotenvy = "0.15.7"
dunce = "1.0.4"
ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] }
encoding_rs = "0.8.35"
env-flags = "0.1.1"
env_logger = "0.11.9"
eventsource-stream = "0.2.3"
flate2 = "1.1.8"
@@ -353,6 +352,7 @@ seccompiler = "0.5.0"
semver = "1.0"
sentry = "0.46.0"
serde = "1"
serde_ignored = "0.1.14"
serde_json = "1"
serde_path_to_error = "0.1.20"
serde_with = "3.17"
@@ -472,6 +472,7 @@ unwrap_used = "deny"
[workspace.metadata.cargo-shear]
ignored = [
"codex-agent-graph-store",
"codex-memories-extension",
"icu_provider",
"openssl-sys",
"codex-v8-poc",
+2
View File
@@ -21,6 +21,8 @@ codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-feedback = { workspace = true }
codex-protocol = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
+107 -22
View File
@@ -25,6 +25,7 @@ use std::io::Result as IoResult;
use std::sync::Arc;
use std::time::Duration;
pub use codex_app_server::app_server_control_socket_path;
pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
pub use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
@@ -61,6 +62,7 @@ use tracing::warn;
pub use crate::remote::RemoteAppServerClient;
pub use crate::remote::RemoteAppServerConnectArgs;
pub use crate::remote::RemoteAppServerEndpoint;
/// Transitional access to core-only embedded app-server types.
///
@@ -334,6 +336,8 @@ pub struct InProcessClientStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Feedback sink used by app-server/core telemetry and logs.
@@ -400,6 +404,7 @@ impl InProcessClientStartArgs {
config: self.config,
cli_overrides: self.cli_overrides,
loader_overrides: self.loader_overrides,
strict_config: self.strict_config,
cloud_requirements: self.cloud_requirements,
thread_config_loader,
feedback: self.feedback,
@@ -952,6 +957,8 @@ mod tests {
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_core::config::ConfigBuilder;
use codex_core::init_state_db;
use codex_uds::UnixListener;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
@@ -961,6 +968,7 @@ 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;
@@ -1025,6 +1033,7 @@ mod tests {
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -1100,9 +1109,10 @@ mod tests {
format!("ws://{addr}")
}
async fn expect_remote_initialize(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) {
async fn expect_remote_initialize<S>(websocket: &mut tokio_tungstenite::WebSocketStream<S>)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else {
panic!("expected initialize request");
};
@@ -1123,9 +1133,12 @@ mod tests {
assert_eq!(notification.method, "initialized");
}
async fn read_websocket_message(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) -> JSONRPCMessage {
async fn read_websocket_message<S>(
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
) -> JSONRPCMessage
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
loop {
let frame = websocket
.next()
@@ -1145,10 +1158,12 @@ mod tests {
}
}
async fn write_websocket_message(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
async fn write_websocket_message<S>(
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
message: JSONRPCMessage,
) {
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
websocket
.send(Message::Text(
serde_json::to_string(&message)
@@ -1213,8 +1228,10 @@ mod tests {
fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs {
RemoteAppServerConnectArgs {
websocket_url,
auth_token: None,
endpoint: RemoteAppServerEndpoint::WebSocket {
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,
@@ -1453,6 +1470,64 @@ mod tests {
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn remote_unix_socket_typed_request_roundtrip_works() {
let socket_dir = TempDir::new().expect("socket dir");
let socket_path = AbsolutePathBuf::from_absolute_path(socket_dir.path().join("codex.sock"))
.expect("socket path should resolve");
let mut listener = UnixListener::bind(socket_path.as_path())
.await
.expect("listener should bind");
tokio::spawn(async move {
let stream = listener.accept().await.expect("accept should succeed");
let mut websocket = accept_async(stream)
.await
.expect("websocket upgrade should succeed");
expect_remote_initialize(&mut websocket).await;
let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await
else {
panic!("expected account/read request");
};
assert_eq!(request.method, "account/read");
write_websocket_message(
&mut websocket,
JSONRPCMessage::Response(JSONRPCResponse {
id: request.id,
result: serde_json::to_value(GetAccountResponse {
account: None,
requires_openai_auth: false,
})
.expect("response should serialize"),
}),
)
.await;
websocket.close(None).await.expect("close should succeed");
});
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await
.expect("remote client should connect");
let response: GetAccountResponse = client
.request_typed(ClientRequest::GetAccount {
request_id: RequestId::Integer(1),
params: codex_app_server_protocol::GetAccountParams {
refresh_token: false,
},
})
.await
.expect("typed request should succeed");
assert_eq!(response.account, None);
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn remote_typed_request_accepts_large_single_frame_response() {
let padding = "x".repeat((17 << 20) + 1024);
@@ -1514,8 +1589,15 @@ mod tests {
)
.await;
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
auth_token: Some(auth_token),
..test_remote_connect_args(websocket_url)
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url,
auth_token: Some(auth_token),
},
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await
.expect("remote client should connect");
@@ -1526,9 +1608,15 @@ mod tests {
#[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())
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://example.com:4500".to_string(),
auth_token: Some("remote-bearer-token".to_string()),
},
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await;
let err = match result {
@@ -1706,13 +1794,8 @@ 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,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
..test_remote_connect_args(websocket_url)
})
.await
.expect("remote client should connect");
@@ -2109,6 +2192,7 @@ mod tests {
config: config.clone(),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -2149,6 +2233,7 @@ mod tests {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
+226 -99
View File
@@ -1,11 +1,13 @@
/*
This module implements the websocket-backed app-server client transport.
This module implements the remote app-server client transport.
It owns the remote connection lifecycle, including the initialize/initialized
handshake, JSON-RPC request/response routing, server-request resolution, and
notification streaming. The rest of the crate uses the same `AppServerEvent`
surface for both in-process and remote transports, so callers such as the TUI
can switch between them without changing their higher-level session logic.
notification streaming. Remote connections always carry WebSocket frames, over
either TCP WebSocket URLs or local Unix sockets. The rest of the crate uses the
same `AppServerEvent` surface for both in-process and remote transports, so
callers such as the TUI can switch between them without changing their
higher-level session logic.
*/
use std::collections::HashMap;
@@ -35,17 +37,23 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Result as JsonRpcResult;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_uds::UnixStream;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use futures::SinkExt;
use futures::StreamExt;
use serde::de::DeserializeOwned;
use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::client_async_with_config;
use tokio_tungstenite::connect_async_with_config;
use tokio_tungstenite::tungstenite::Error as TungsteniteError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
@@ -57,18 +65,30 @@ use url::Url;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE: usize = 128 << 20;
// Tungstenite still needs an HTTP request URI for the WebSocket handshake;
// the bytes travel over the Unix socket, not TCP.
const UDS_WEBSOCKET_HANDSHAKE_URL: &str = "ws://localhost/rpc";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteAppServerEndpoint {
WebSocket {
websocket_url: String,
auth_token: Option<String>,
},
UnixSocket {
socket_path: AbsolutePathBuf,
},
}
#[derive(Debug, Clone)]
pub struct RemoteAppServerConnectArgs {
pub websocket_url: String,
pub auth_token: Option<String>,
pub endpoint: RemoteAppServerEndpoint,
pub client_name: String,
pub client_version: String,
pub experimental_api: bool,
pub opt_out_notification_methods: Vec<String>,
pub channel_capacity: usize,
}
impl RemoteAppServerConnectArgs {
fn initialize_params(&self) -> InitializeParams {
let capabilities = InitializeCapabilities {
@@ -141,69 +161,39 @@ pub struct RemoteAppServerRequestHandle {
impl RemoteAppServerClient {
pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult<Self> {
let channel_capacity = args.channel_capacity.max(1);
let websocket_url = args.websocket_url.clone();
let url = Url::parse(&websocket_url).map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid websocket URL `{websocket_url}`: {err}"),
)
})?;
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 initialize_params = args.initialize_params();
match args.endpoint {
RemoteAppServerEndpoint::WebSocket {
websocket_url,
auth_token,
} => {
let (endpoint, stream) =
connect_websocket_endpoint(websocket_url, auth_token).await?;
Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params)
.await
}
RemoteAppServerEndpoint::UnixSocket { socket_path } => {
let (endpoint, stream) = connect_unix_socket_endpoint(socket_path).await?;
Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params)
.await
}
}
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);
}
ensure_rustls_crypto_provider();
// Remote resume responses can legitimately carry large thread histories.
// Keep a bounded cap, but raise it above tungstenite's 16 MiB frame default.
let websocket_config = WebSocketConfig::default()
.max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE))
.max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE));
let stream = timeout(
CONNECT_TIMEOUT,
connect_async_with_config(
request,
Some(websocket_config),
/*disable_nagle*/ false,
),
)
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out connecting to remote app server at `{websocket_url}`"),
)
})?
.map(|(stream, _response)| stream)
.map_err(|err| {
IoError::other(format!(
"failed to connect to remote app server at `{websocket_url}`: {err}"
))
})?;
}
async fn connect_with_stream<S>(
channel_capacity: usize,
endpoint: String,
stream: WebSocketStream<S>,
initialize_params: InitializeParams,
) -> IoResult<Self>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut stream = stream;
let pending_events = initialize_remote_connection(
&mut stream,
&websocket_url,
args.initialize_params(),
&endpoint,
initialize_params,
INITIALIZE_TIMEOUT,
)
.await?;
@@ -235,13 +225,13 @@ impl RemoteAppServerClient {
if let Err(err) = write_jsonrpc_message(
&mut stream,
JSONRPCMessage::Request(jsonrpc_request_from_client_request(*request)),
&websocket_url,
&endpoint,
)
.await
{
let err_message = err.to_string();
let message = format!(
"remote app server at `{websocket_url}` write failed: {err_message}"
"remote app server at `{endpoint}` write failed: {err_message}"
);
if let Some(response_tx) = pending_requests.remove(&request_id) {
let _ = response_tx.send(Err(err));
@@ -262,7 +252,7 @@ impl RemoteAppServerClient {
JSONRPCMessage::Notification(
jsonrpc_notification_from_client_notification(notification),
),
&websocket_url,
&endpoint,
)
.await;
let _ = response_tx.send(result);
@@ -278,7 +268,7 @@ impl RemoteAppServerClient {
id: request_id,
result,
}),
&websocket_url,
&endpoint,
)
.await;
let _ = response_tx.send(result);
@@ -294,16 +284,20 @@ impl RemoteAppServerClient {
error,
id: request_id,
}),
&websocket_url,
&endpoint,
)
.await;
let _ = response_tx.send(result);
}
RemoteClientCommand::Shutdown { response_tx } => {
let close_result = stream.close(None).await.map_err(|err| {
IoError::other(format!(
"failed to close websocket app server `{websocket_url}`: {err}"
))
let close_result = stream.close(None).await.or_else(|err| {
if websocket_close_error_is_already_closed(&err) {
Ok(())
} else {
Err(IoError::other(format!(
"failed to close websocket app server `{endpoint}`: {err}"
)))
}
});
let _ = response_tx.send(close_result);
break;
@@ -364,13 +358,13 @@ impl RemoteAppServerClient {
},
id: request_id,
}),
&websocket_url,
&endpoint,
)
.await
{
let err_message = reject_err.to_string();
let message = format!(
"remote app server at `{websocket_url}` write failed: {err_message}"
"remote app server at `{endpoint}` write failed: {err_message}"
);
let _ = deliver_event(
&event_tx,
@@ -387,7 +381,7 @@ impl RemoteAppServerClient {
}
Err(err) => {
let message = format!(
"remote app server at `{websocket_url}` sent invalid JSON-RPC: {err}"
"remote app server at `{endpoint}` sent invalid JSON-RPC: {err}"
);
let _ = deliver_event(
&event_tx,
@@ -408,7 +402,7 @@ impl RemoteAppServerClient {
.filter(|reason| !reason.is_empty())
.unwrap_or_else(|| "connection closed".to_string());
let message = format!(
"remote app server at `{websocket_url}` disconnected: {reason}"
"remote app server at `{endpoint}` disconnected: {reason}"
);
let _ = deliver_event(
&event_tx,
@@ -428,7 +422,7 @@ impl RemoteAppServerClient {
| Some(Ok(Message::Frame(_))) => {}
Some(Err(err)) => {
let message = format!(
"remote app server at `{websocket_url}` transport failed: {err}"
"remote app server at `{endpoint}` transport failed: {err}"
);
let _ = deliver_event(
&event_tx,
@@ -441,7 +435,7 @@ impl RemoteAppServerClient {
}
None => {
let message = format!(
"remote app server at `{websocket_url}` closed the connection"
"remote app server at `{endpoint}` closed the connection"
);
let _ = deliver_event(
&event_tx,
@@ -678,12 +672,131 @@ impl RemoteAppServerRequestHandle {
}
}
async fn initialize_remote_connection(
stream: &mut WebSocketStream<MaybeTlsStream<TcpStream>>,
websocket_url: &str,
async fn connect_websocket_endpoint(
websocket_url: String,
auth_token: Option<String>,
) -> IoResult<(String, WebSocketStream<MaybeTlsStream<TcpStream>>)> {
let url = Url::parse(&websocket_url).map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid websocket URL `{websocket_url}`: {err}"),
)
})?;
if 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) = 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);
}
ensure_rustls_crypto_provider();
let websocket_config = remote_websocket_config();
let stream = timeout(
CONNECT_TIMEOUT,
connect_async_with_config(
request,
Some(websocket_config),
/*disable_nagle*/ false,
),
)
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out connecting to remote app server at `{websocket_url}`"),
)
})?
.map(|(stream, _response)| stream)
.map_err(|err| {
IoError::other(format!(
"failed to connect to remote app server at `{websocket_url}`: {err}"
))
})?;
Ok((websocket_url, stream))
}
async fn connect_unix_socket_endpoint(
socket_path: AbsolutePathBuf,
) -> IoResult<(String, WebSocketStream<UnixStream>)> {
let endpoint = format!("unix://{}", socket_path.display());
let request = UDS_WEBSOCKET_HANDSHAKE_URL
.into_client_request()
.map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid UDS websocket handshake URL: {err}"),
)
})?;
let stream = timeout(CONNECT_TIMEOUT, UnixStream::connect(socket_path.as_path()))
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out connecting to remote app server at `{endpoint}`"),
)
})?
.map_err(|err| {
IoError::other(format!(
"failed to connect to remote app server at `{endpoint}`: {err}"
))
})?;
let websocket_config = remote_websocket_config();
let stream = timeout(
CONNECT_TIMEOUT,
client_async_with_config(request, stream, Some(websocket_config)),
)
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out upgrading remote app server at `{endpoint}`"),
)
})?
.map(|(stream, _response)| stream)
.map_err(|err| {
IoError::other(format!(
"failed to upgrade remote app server at `{endpoint}`: {err}"
))
})?;
Ok((endpoint, stream))
}
fn remote_websocket_config() -> WebSocketConfig {
WebSocketConfig::default()
.max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE))
.max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE))
}
async fn initialize_remote_connection<S>(
stream: &mut WebSocketStream<S>,
endpoint: &str,
params: InitializeParams,
initialize_timeout: Duration,
) -> IoResult<Vec<AppServerEvent>> {
) -> IoResult<Vec<AppServerEvent>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let initialize_request_id = RequestId::String("initialize".to_string());
let mut pending_events = Vec::new();
write_jsonrpc_message(
@@ -694,7 +807,7 @@ async fn initialize_remote_connection(
params,
},
)),
websocket_url,
endpoint,
)
.await?;
@@ -704,7 +817,7 @@ async fn initialize_remote_connection(
Some(Ok(Message::Text(text))) => {
let message = serde_json::from_str::<JSONRPCMessage>(&text).map_err(|err| {
IoError::other(format!(
"remote app server at `{websocket_url}` sent invalid initialize response: {err}"
"remote app server at `{endpoint}` sent invalid initialize response: {err}"
))
})?;
match message {
@@ -713,7 +826,7 @@ async fn initialize_remote_connection(
}
JSONRPCMessage::Error(error) if error.id == initialize_request_id => {
break Err(IoError::other(format!(
"remote app server at `{websocket_url}` rejected initialize: {}",
"remote app server at `{endpoint}` rejected initialize: {}",
error.error.message
)));
}
@@ -743,7 +856,7 @@ async fn initialize_remote_connection(
},
id: request_id,
}),
websocket_url,
endpoint,
)
.await?;
}
@@ -765,19 +878,19 @@ async fn initialize_remote_connection(
break Err(IoError::new(
ErrorKind::ConnectionAborted,
format!(
"remote app server at `{websocket_url}` closed during initialize: {reason}"
"remote app server at `{endpoint}` closed during initialize: {reason}"
),
));
}
Some(Err(err)) => {
break Err(IoError::other(format!(
"remote app server at `{websocket_url}` transport failed during initialize: {err}"
"remote app server at `{endpoint}` transport failed during initialize: {err}"
)));
}
None => {
break Err(IoError::new(
ErrorKind::UnexpectedEof,
format!("remote app server at `{websocket_url}` closed during initialize"),
format!("remote app server at `{endpoint}` closed during initialize"),
));
}
}
@@ -787,7 +900,7 @@ async fn initialize_remote_connection(
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out waiting for initialize response from `{websocket_url}`"),
format!("timed out waiting for initialize response from `{endpoint}`"),
)
})??;
@@ -796,7 +909,7 @@ async fn initialize_remote_connection(
JSONRPCMessage::Notification(jsonrpc_notification_from_client_notification(
ClientNotification::Initialized,
)),
websocket_url,
endpoint,
)
.await?;
@@ -850,21 +963,35 @@ fn jsonrpc_notification_from_client_notification(
}
}
async fn write_jsonrpc_message(
stream: &mut WebSocketStream<MaybeTlsStream<TcpStream>>,
async fn write_jsonrpc_message<S>(
stream: &mut WebSocketStream<S>,
message: JSONRPCMessage,
websocket_url: &str,
) -> IoResult<()> {
endpoint: &str,
) -> IoResult<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let payload = serde_json::to_string(&message).map_err(IoError::other)?;
stream
.send(Message::Text(payload.into()))
.await
.map_err(|err| {
IoError::other(format!(
"failed to write websocket message to `{websocket_url}`: {err}"
"failed to write websocket message to `{endpoint}`: {err}"
))
})
}
fn websocket_close_error_is_already_closed(err: &TungsteniteError) -> bool {
match err {
TungsteniteError::ConnectionClosed | TungsteniteError::AlreadyClosed => true,
TungsteniteError::Io(err) => matches!(
err.kind(),
ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::NotConnected
),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2015,6 +2015,17 @@
],
"type": "object"
},
"PluginShareCheckoutParams": {
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginShareDeleteParams": {
"properties": {
"remotePluginId": {
@@ -5075,6 +5086,30 @@
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/checkout"
],
"title": "Plugin/share/checkoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareCheckoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/checkoutRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -877,6 +877,30 @@
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/checkout"
],
"title": "Plugin/share/checkoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareCheckoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/checkoutRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7540,6 +7564,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/v2/AskForApproval"
@@ -12262,6 +12292,58 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareCheckoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareCheckoutParams",
"type": "object"
},
"PluginShareCheckoutResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"pluginId": {
"type": "string"
},
"pluginName": {
"type": "string"
},
"pluginPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginId",
"pluginName",
"pluginPath",
"remotePluginId"
],
"title": "PluginShareCheckoutResponse",
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
@@ -12289,6 +12371,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/v2/PluginSharePrincipal"
@@ -12705,6 +12795,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -1617,6 +1617,30 @@
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/checkout"
],
"title": "Plugin/share/checkoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareCheckoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/checkoutRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3929,6 +3953,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"
@@ -8811,6 +8841,58 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareCheckoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareCheckoutParams",
"type": "object"
},
"PluginShareCheckoutResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginId": {
"type": "string"
},
"pluginName": {
"type": "string"
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginId",
"pluginName",
"pluginPath",
"remotePluginId"
],
"title": "PluginShareCheckoutResponse",
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
@@ -8838,6 +8920,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -9254,6 +9344,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -62,6 +62,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"
@@ -259,6 +259,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -449,6 +457,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -313,6 +313,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -503,6 +511,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareCheckoutParams",
"type": "object"
}
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginId": {
"type": "string"
},
"pluginName": {
"type": "string"
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginId",
"pluginName",
"pluginPath",
"remotePluginId"
],
"title": "PluginShareCheckoutResponse",
"type": "object"
}
@@ -194,6 +194,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -405,6 +413,14 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
File diff suppressed because one or more lines are too long
@@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginShareCheckoutParams = { remotePluginId: string, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginShareCheckoutResponse = { remotePluginId: string, pluginId: string, pluginName: string, pluginPath: AbsolutePathBuf, marketplaceName: string, marketplacePath: AbsolutePathBuf, remoteVersion: string | null, };
@@ -4,4 +4,8 @@
import type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
import type { PluginSharePrincipal } from "./PluginSharePrincipal";
export type PluginShareContext = { remotePluginId: string, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
export type PluginShareContext = { remotePluginId: string,
/**
* Version of the remote shared plugin release when available.
*/
remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
@@ -12,7 +12,11 @@ export type PluginSummary = { id: string,
/**
* Backend remote plugin identifier when available.
*/
remotePluginId: string | null, name: string,
remotePluginId: string | null,
/**
* Version of the locally materialized plugin package when available.
*/
localVersion: string | null, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
@@ -277,6 +277,8 @@ export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams";
export type { PluginShareCheckoutResponse } from "./PluginShareCheckoutResponse";
export type { PluginShareContext } from "./PluginShareContext";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
@@ -621,12 +621,12 @@ client_request_definitions! {
},
PluginList => "plugin/list" {
params: v2::PluginListParams,
serialization: global_shared_read("config"),
serialization: global_shared_read("plugin-read"),
response: v2::PluginListResponse,
},
PluginRead => "plugin/read" {
params: v2::PluginReadParams,
serialization: global("config"),
serialization: global_shared_read("plugin-read"),
response: v2::PluginReadResponse,
},
PluginSkillRead => "plugin/skill/read" {
@@ -649,6 +649,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginShareListResponse,
},
PluginShareCheckout => "plugin/share/checkout" {
params: v2::PluginShareCheckoutParams,
serialization: global("config"),
response: v2::PluginShareCheckoutResponse,
},
PluginShareDelete => "plugin/share/delete" {
params: v2::PluginShareDeleteParams,
serialization: global("config"),
@@ -1685,7 +1690,24 @@ mod tests {
};
assert_eq!(
plugin_list.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead("config"))
Some(ClientRequestSerializationScope::GlobalSharedRead(
"plugin-read"
))
);
let plugin_read = ClientRequest::PluginRead {
request_id: request_id(),
params: v2::PluginReadParams {
marketplace_path: Some(absolute_path("/tmp/marketplace")),
remote_marketplace_name: None,
plugin_name: "plugin-a".to_string(),
},
};
assert_eq!(
plugin_read.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead(
"plugin-read"
))
);
let plugin_uninstall = ClientRequest::PluginUninstall {
@@ -357,6 +357,7 @@ pub struct ConfigRequirements {
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
#[experimental("configRequirements/read.hooks")]
pub hooks: Option<ManagedHooksRequirements>,
@@ -242,6 +242,26 @@ pub struct PluginShareListResponse {
pub data: Vec<PluginShareListItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareCheckoutParams {
pub remote_plugin_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareCheckoutResponse {
pub remote_plugin_id: String,
pub plugin_id: String,
pub plugin_name: String,
pub plugin_path: AbsolutePathBuf,
pub marketplace_name: String,
pub marketplace_path: AbsolutePathBuf,
pub remote_version: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -540,6 +560,9 @@ pub struct PluginSummary {
pub id: String,
/// Backend remote plugin identifier when available.
pub remote_plugin_id: Option<String>,
/// Version of the locally materialized plugin package when available.
#[serde(default)]
pub local_version: Option<String>,
pub name: String,
/// Remote sharing context associated with this plugin when available.
pub share_context: Option<PluginShareContext>,
@@ -561,6 +584,9 @@ pub struct PluginSummary {
#[ts(export_to = "v2/")]
pub struct PluginShareContext {
pub remote_plugin_id: String,
/// Version of the remote shared plugin release when available.
#[serde(default)]
pub remote_version: Option<String>,
pub discoverability: Option<PluginShareDiscoverability>,
pub share_url: Option<String>,
pub creator_account_user_id: Option<String>,
@@ -1688,6 +1688,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
enforce_residency: None,
@@ -2986,6 +2987,52 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
PluginShareListParams {},
);
assert_eq!(
serde_json::to_value(PluginShareCheckoutParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
}),
);
let plugin_path = if cfg!(windows) {
r"C:\Users\me\plugins\gmail"
} else {
"/Users/me/plugins/gmail"
};
let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap();
let plugin_path_json = plugin_path.as_path().display().to_string();
let marketplace_path = if cfg!(windows) {
r"C:\Users\me\.agents\plugins\marketplace.json"
} else {
"/Users/me/.agents/plugins/marketplace.json"
};
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
let marketplace_path_json = marketplace_path.as_path().display().to_string();
assert_eq!(
serde_json::to_value(PluginShareCheckoutResponse {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
plugin_id: "gmail@codex-curated".to_string(),
plugin_name: "gmail".to_string(),
plugin_path,
marketplace_name: "codex-curated".to_string(),
marketplace_path,
remote_version: Some("1.2.3".to_string()),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"pluginId": "gmail@codex-curated",
"pluginName": "gmail",
"pluginPath": plugin_path_json,
"marketplaceName": "codex-curated",
"marketplacePath": marketplace_path_json,
"remoteVersion": "1.2.3",
}),
);
assert_eq!(
serde_json::to_value(PluginShareDeleteParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
@@ -3007,6 +3054,7 @@ fn plugin_share_list_response_serializes_share_items() {
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
local_version: None,
name: "gmail".to_string(),
share_context: None,
source: PluginSource::Remote,
@@ -3027,6 +3075,7 @@ fn plugin_share_list_response_serializes_share_items() {
"plugin": {
"id": "gmail@chatgpt-global",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"localVersion": null,
"name": "gmail",
"shareContext": null,
"source": { "type": "remote" },
@@ -3059,6 +3108,7 @@ fn plugin_summary_defaults_missing_availability_to_available() {
.unwrap();
assert_eq!(summary.availability, PluginAvailability::Available);
assert_eq!(summary.local_version, None);
assert_eq!(summary.share_context, None);
}
+14
View File
@@ -13,7 +13,15 @@ doctest = false
workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
"ws",
] }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
@@ -23,10 +31,16 @@ codex-state = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
+2
View File
@@ -14,6 +14,8 @@ pub use transport::RemoteControlHandle;
pub use transport::RemoteControlStartConfig;
pub use transport::TransportEvent;
pub use transport::app_server_control_socket_path;
pub use transport::auth;
pub use transport::start_control_socket_acceptor;
pub use transport::start_remote_control;
pub use transport::start_stdio_connection;
pub use transport::start_websocket_acceptor;
@@ -0,0 +1,751 @@
use anyhow::Context;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use clap::Args;
use clap::ValueEnum;
use codex_utils_absolute_path::AbsolutePathBuf;
use constant_time_eq::constant_time_eq_32;
use jsonwebtoken::Algorithm;
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use jsonwebtoken::decode;
use serde::Deserialize;
use sha2::Digest;
use sha2::Sha256;
use std::io;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use time::OffsetDateTime;
const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30;
const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32;
const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header";
#[derive(Debug, Clone, Default, PartialEq, Eq, Args)]
pub struct AppServerWebsocketAuthArgs {
/// Websocket auth mode for non-loopback listeners.
#[arg(long = "ws-auth", value_name = "MODE", value_enum)]
pub ws_auth: Option<WebsocketAuthCliMode>,
/// Absolute path to the capability-token file.
#[arg(long = "ws-token-file", value_name = "PATH")]
pub ws_token_file: Option<PathBuf>,
/// Hex-encoded SHA-256 digest of the capability token.
#[arg(long = "ws-token-sha256", value_name = "HEX")]
pub ws_token_sha256: Option<String>,
/// Absolute path to the shared secret file for signed JWT bearer tokens.
#[arg(long = "ws-shared-secret-file", value_name = "PATH")]
pub ws_shared_secret_file: Option<PathBuf>,
/// Expected issuer for signed JWT bearer tokens.
#[arg(long = "ws-issuer", value_name = "ISSUER")]
pub ws_issuer: Option<String>,
/// Expected audience for signed JWT bearer tokens.
#[arg(long = "ws-audience", value_name = "AUDIENCE")]
pub ws_audience: Option<String>,
/// Maximum clock skew when validating signed JWT bearer tokens.
#[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")]
pub ws_max_clock_skew_seconds: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum WebsocketAuthCliMode {
CapabilityToken,
SignedBearerToken,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppServerWebsocketAuthSettings {
pub config: Option<AppServerWebsocketAuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketAuthConfig {
CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource,
},
SignedBearerToken {
shared_secret_file: AbsolutePathBuf,
issuer: Option<String>,
audience: Option<String>,
max_clock_skew_seconds: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketCapabilityTokenSource {
TokenFile { token_file: AbsolutePathBuf },
TokenSha256 { token_sha256: [u8; 32] },
}
#[derive(Clone, Debug, Default)]
pub struct WebsocketAuthPolicy {
pub(crate) mode: Option<WebsocketAuthMode>,
}
#[derive(Clone, Debug)]
pub(crate) enum WebsocketAuthMode {
CapabilityToken {
token_sha256: [u8; 32],
},
SignedBearerToken {
shared_secret: Vec<u8>,
issuer: Option<String>,
audience: Option<String>,
max_clock_skew_seconds: i64,
},
}
#[derive(Debug)]
pub(crate) struct WebsocketAuthError {
status_code: StatusCode,
message: &'static str,
}
#[derive(Deserialize)]
struct JwtClaims {
exp: i64,
nbf: Option<i64>,
iss: Option<String>,
aud: Option<JwtAudienceClaim>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum JwtAudienceClaim {
Single(String),
Multiple(Vec<String>),
}
impl WebsocketAuthError {
pub(crate) fn status_code(&self) -> StatusCode {
self.status_code
}
pub(crate) fn message(&self) -> &'static str {
self.message
}
}
impl AppServerWebsocketAuthArgs {
pub fn try_into_settings(self) -> anyhow::Result<AppServerWebsocketAuthSettings> {
let normalize = |value: Option<String>| {
value.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
};
let config = match self.ws_auth {
Some(WebsocketAuthCliMode::CapabilityToken) => {
if self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
|| self.ws_max_clock_skew_seconds.is_some()
{
anyhow::bail!(
"`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`"
);
}
let source = match (self.ws_token_file, self.ws_token_sha256) {
(Some(_), Some(_)) => {
anyhow::bail!(
"`--ws-token-file` and `--ws-token-sha256` are mutually exclusive"
);
}
(Some(token_file), None) => {
AppServerWebsocketCapabilityTokenSource::TokenFile {
token_file: absolute_path_arg("--ws-token-file", token_file)?,
}
}
(None, Some(token_sha256)) => {
AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?,
}
}
(None, None) => {
anyhow::bail!(
"`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set"
);
}
};
Some(AppServerWebsocketAuthConfig::CapabilityToken { source })
}
Some(WebsocketAuthCliMode::SignedBearerToken) => {
if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() {
anyhow::bail!(
"`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`"
);
}
let shared_secret_file = self.ws_shared_secret_file.context(
"`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set",
)?;
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file: absolute_path_arg(
"--ws-shared-secret-file",
shared_secret_file,
)?,
issuer: normalize(self.ws_issuer),
audience: normalize(self.ws_audience),
max_clock_skew_seconds: self
.ws_max_clock_skew_seconds
.unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS),
})
}
None => {
if self.ws_token_file.is_some()
|| self.ws_token_sha256.is_some()
|| self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
|| self.ws_max_clock_skew_seconds.is_some()
{
anyhow::bail!(
"websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
);
}
None
}
};
Ok(AppServerWebsocketAuthSettings { config })
}
}
pub fn policy_from_settings(
settings: &AppServerWebsocketAuthSettings,
) -> io::Result<WebsocketAuthPolicy> {
let mode = match settings.config.as_ref() {
Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source {
AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => {
let token = read_trimmed_secret(token_file.as_ref())?;
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: sha256_digest(token.as_bytes()),
})
}
AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => {
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: *token_sha256,
})
}
},
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file,
issuer,
audience,
max_clock_skew_seconds,
}) => {
let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes();
validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?;
let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| {
io::Error::new(
ErrorKind::InvalidInput,
"websocket auth clock skew must fit in a signed 64-bit integer",
)
})?;
Some(WebsocketAuthMode::SignedBearerToken {
shared_secret,
issuer: issuer.clone(),
audience: audience.clone(),
max_clock_skew_seconds,
})
}
None => None,
};
Ok(WebsocketAuthPolicy { mode })
}
pub(crate) fn is_unauthenticated_non_loopback_listener(
bind_address: SocketAddr,
policy: &WebsocketAuthPolicy,
) -> bool {
!bind_address.ip().is_loopback() && policy.mode.is_none()
}
pub(crate) fn authorize_upgrade(
headers: &HeaderMap,
policy: &WebsocketAuthPolicy,
) -> Result<(), WebsocketAuthError> {
let Some(mode) = policy.mode.as_ref() else {
return Ok(());
};
let token = bearer_token_from_headers(headers)?;
match mode {
WebsocketAuthMode::CapabilityToken { token_sha256 } => {
let actual_sha256 = sha256_digest(token.as_bytes());
if constant_time_eq_32(token_sha256, &actual_sha256) {
Ok(())
} else {
Err(unauthorized("invalid websocket bearer token"))
}
}
WebsocketAuthMode::SignedBearerToken {
shared_secret,
issuer,
audience,
max_clock_skew_seconds,
} => verify_signed_bearer_token(
token,
shared_secret,
issuer.as_deref(),
audience.as_deref(),
*max_clock_skew_seconds,
),
}
}
fn verify_signed_bearer_token(
token: &str,
shared_secret: &[u8],
issuer: Option<&str>,
audience: Option<&str>,
max_clock_skew_seconds: i64,
) -> Result<(), WebsocketAuthError> {
let claims = decode_jwt_claims(token, shared_secret)?;
validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds)
}
fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result<JwtClaims, WebsocketAuthError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.required_spec_claims.clear();
validation.validate_exp = false;
validation.validate_nbf = false;
validation.validate_aud = false;
decode::<JwtClaims>(token, &DecodingKey::from_secret(shared_secret), &validation)
.map(|token_data| token_data.claims)
.map_err(|_| unauthorized("invalid websocket jwt"))
}
fn validate_jwt_claims(
claims: &JwtClaims,
issuer: Option<&str>,
audience: Option<&str>,
max_clock_skew_seconds: i64,
) -> Result<(), WebsocketAuthError> {
let now = OffsetDateTime::now_utc().unix_timestamp();
if now > claims.exp.saturating_add(max_clock_skew_seconds) {
return Err(unauthorized("expired websocket jwt"));
}
if let Some(nbf) = claims.nbf
&& now < nbf.saturating_sub(max_clock_skew_seconds)
{
return Err(unauthorized("websocket jwt is not valid yet"));
}
if let Some(expected_issuer) = issuer
&& claims.iss.as_deref() != Some(expected_issuer)
{
return Err(unauthorized("websocket jwt issuer mismatch"));
}
if let Some(expected_audience) = audience
&& !audience_matches(claims.aud.as_ref(), expected_audience)
{
return Err(unauthorized("websocket jwt audience mismatch"));
}
Ok(())
}
fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool {
match audience {
Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience,
Some(JwtAudienceClaim::Multiple(actual)) => {
actual.iter().any(|audience| audience == expected_audience)
}
None => false,
}
}
fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> {
let raw_header = headers
.get(AUTHORIZATION)
.ok_or_else(|| unauthorized("missing websocket bearer token"))?;
let header = raw_header
.to_str()
.map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?;
let Some((scheme, token)) = header.split_once(' ') else {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
};
if !scheme.eq_ignore_ascii_case("Bearer") {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
}
let token = token.trim();
if token.is_empty() {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
}
Ok(token)
}
fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> {
if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES {
return Err(io::Error::new(
ErrorKind::InvalidInput,
format!(
"signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes",
path.display()
),
));
}
Ok(())
}
fn read_trimmed_secret(path: &std::path::Path) -> io::Result<String> {
let raw = std::fs::read_to_string(path).map_err(|err| {
io::Error::new(
err.kind(),
format!(
"failed to read websocket auth secret {}: {err}",
path.display()
),
)
})?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
format!("websocket auth secret {} must not be empty", path.display()),
));
}
Ok(trimmed.to_string())
}
fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path"))
}
fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> {
let trimmed = value.trim();
if trimmed.len() != 64 {
anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest");
}
let mut digest = [0u8; 32];
for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() {
let high = hex_nibble(flag_name, pair[0])?;
let low = hex_nibble(flag_name, pair[1])?;
digest[index] = (high << 4) | low;
}
Ok(digest)
}
fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result<u8> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"),
}
}
fn sha256_digest(input: &[u8]) -> [u8; 32] {
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(input));
digest
}
fn unauthorized(message: &'static str) -> WebsocketAuthError {
WebsocketAuthError {
status_code: StatusCode::UNAUTHORIZED,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::Hmac;
use hmac::Mac;
use serde_json::json;
type HmacSha256 = Hmac<Sha256>;
fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
let payload = format!("{header}.{claims_segment}");
let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap();
mac.update(payload.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{payload}.{signature}")
}
#[test]
fn detects_unauthenticated_non_loopback_listener() {
let policy = WebsocketAuthPolicy::default();
assert!(is_unauthenticated_non_loopback_listener(
"0.0.0.0:8765".parse().unwrap(),
&policy,
));
assert!(!is_unauthenticated_non_loopback_listener(
"127.0.0.1:8765".parse().unwrap(),
&policy,
));
assert!(!is_unauthenticated_non_loopback_listener(
"0.0.0.0:8765".parse().unwrap(),
&WebsocketAuthPolicy {
mode: Some(WebsocketAuthMode::CapabilityToken {
token_sha256: [0u8; 32],
}),
},
));
}
#[test]
fn capability_token_args_require_token_file_or_hash() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should require a token source");
assert!(
err.to_string().contains("--ws-token-file")
&& err.to_string().contains("--ws-token-sha256"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_args_accept_token_hash() {
let settings = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_sha256: Some("ab".repeat(32)),
..Default::default()
}
.try_into_settings()
.expect("capability-token hash args should parse");
assert_eq!(
settings,
AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: [0xab; 32],
},
}),
}
);
}
#[test]
fn capability_token_args_reject_multiple_token_sources() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_file: Some(PathBuf::from("/tmp/token")),
ws_token_sha256: Some("ab".repeat(32)),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should reject multiple token sources");
assert!(
err.to_string().contains("mutually exclusive"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_args_reject_malformed_token_hash() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_sha256: Some("not-a-sha256".to_string()),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should reject malformed token hashes");
assert!(
err.to_string().contains("64-character hex"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_hash_policy_authorizes_matching_bearer_token() {
let settings = AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: sha256_digest(b"super-secret-token"),
},
}),
};
let policy = policy_from_settings(&settings).expect("hash policy should build");
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer super-secret-token"),
);
authorize_upgrade(&headers, &policy).expect("matching token should authorize");
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer wrong-token"),
);
let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() {
let err = AppServerWebsocketAuthArgs {
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
..Default::default()
}
.try_into_settings()
.expect_err("mode-specific flags should require --ws-auth");
assert!(
err.to_string().contains("websocket auth flags require"),
"unexpected error: {err}"
);
}
#[test]
fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() {
let settings = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken),
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
ws_issuer: Some(" issuer ".to_string()),
ws_audience: Some(" ".to_string()),
..Default::default()
}
.try_into_settings()
.expect("signed bearer args should parse");
assert_eq!(
settings,
AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret")
.expect("absolute path"),
issuer: Some("issuer".to_string()),
audience: None,
max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS,
}),
}
);
}
#[test]
fn signed_bearer_token_verification_rejects_tampering() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
}),
);
let tampered = token.replace(".eyJleHAi", ".eyJleHBi");
let err = verify_signed_bearer_token(
&tampered,
shared_secret,
/*issuer*/ None,
/*audience*/ None,
/*max_clock_skew_seconds*/ 30,
)
.expect_err("tampered jwt should fail");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_token_verification_accepts_valid_token() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "issuer",
"aud": "audience",
}),
);
verify_signed_bearer_token(
&token,
shared_secret,
Some("issuer"),
Some("audience"),
/*max_clock_skew_seconds*/ 30,
)
.expect("valid signed token should verify");
}
#[test]
fn signed_bearer_token_verification_accepts_multiple_audiences() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"aud": ["other-audience", "audience"],
}),
);
verify_signed_bearer_token(
&token,
shared_secret,
/*issuer*/ None,
Some("audience"),
/*max_clock_skew_seconds*/ 30,
)
.expect("jwt audience arrays should verify");
}
#[test]
fn signed_bearer_token_verification_rejects_alg_none_tokens() {
let claims_segment = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
}))
.unwrap(),
);
let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let token = format!("{header_segment}.{claims_segment}.");
let err = verify_signed_bearer_token(
&token,
b"0123456789abcdef0123456789abcdef",
/*issuer*/ None,
/*audience*/ None,
/*max_clock_skew_seconds*/ 30,
)
.expect_err("alg=none jwt should be rejected");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_token_verification_rejects_missing_exp() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"iss": "issuer",
}),
);
let err = verify_signed_bearer_token(
&token,
shared_secret,
/*issuer*/ None,
/*audience*/ None,
/*max_clock_skew_seconds*/ 30,
)
.expect_err("jwt without exp should be rejected");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn validate_signed_bearer_secret_rejects_short_secret() {
let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short")
.expect_err("short shared secret should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(
err.to_string().contains("must be at least 32 bytes"),
"unexpected error: {err}"
);
}
}
@@ -1,3 +1,5 @@
pub mod auth;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
@@ -6,6 +8,7 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_core::config::find_codex_home;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::net::SocketAddr;
use std::path::Path;
use std::str::FromStr;
use std::sync::atomic::AtomicU64;
@@ -32,6 +35,7 @@ pub use remote_control::RemoteControlStartConfig;
pub use remote_control::start_remote_control;
pub use stdio::start_stdio_connection;
pub use unix_socket::start_control_socket_acceptor;
pub use websocket::start_websocket_acceptor;
const OVERLOADED_ERROR_CODE: i64 = -32001;
@@ -50,6 +54,7 @@ pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result<Abso
pub enum AppServerTransport {
Stdio,
UnixSocket { socket_path: AbsolutePathBuf },
WebSocket { bind_address: SocketAddr },
Off,
}
@@ -57,6 +62,7 @@ pub enum AppServerTransport {
pub enum AppServerTransportParseError {
UnsupportedListenUrl(String),
InvalidUnixSocketPath { listen_url: String, message: String },
InvalidWebSocketListenUrl(String),
}
impl std::fmt::Display for AppServerTransportParseError {
@@ -64,7 +70,7 @@ impl std::fmt::Display for AppServerTransportParseError {
match self {
AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!(
f,
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, or `off`"
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`"
),
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url,
@@ -73,6 +79,10 @@ impl std::fmt::Display for AppServerTransportParseError {
f,
"invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}"
),
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!(
f,
"invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`"
),
}
}
}
@@ -116,6 +126,13 @@ impl AppServerTransport {
return Ok(Self::Off);
}
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
let bind_address = socket_addr.parse::<SocketAddr>().map_err(|_| {
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string())
})?;
return Ok(Self::WebSocket { bind_address });
}
Err(AppServerTransportParseError::UnsupportedListenUrl(
listen_url.to_string(),
))
@@ -151,7 +168,7 @@ pub enum TransportEvent {
pub enum ConnectionOrigin {
Stdio,
InProcess,
UnixSocket,
WebSocket,
RemoteControl,
}
@@ -1,6 +1,7 @@
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RemoteControlTarget;
use axum::http::HeaderMap;
use codex_api::SharedAuthProvider;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
@@ -8,7 +9,6 @@ use codex_state::StateRuntime;
use gethostname::gethostname;
use std::io;
use std::io::ErrorKind;
use tokio_tungstenite::tungstenite::http::HeaderMap;
use tracing::info;
use tracing::warn;
@@ -19,6 +19,7 @@ use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use axum::http::HeaderValue;
use base64::Engine;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
@@ -47,7 +48,6 @@ use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::info;
@@ -52,14 +52,6 @@ fn listen_unix_socket_accepts_relative_custom_path() {
);
}
#[test]
fn listen_websocket_url_is_not_supported() {
assert!(matches!(
AppServerTransport::from_listen_url("ws://127.0.0.1:0"),
Err(super::AppServerTransportParseError::UnsupportedListenUrl(_))
));
}
#[tokio::test]
async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() {
let temp_dir = tempfile::TempDir::new().expect("temp dir");
@@ -1,17 +1,46 @@
use super::CHANNEL_CAPACITY;
use super::ConnectionOrigin;
use super::TransportEvent;
use super::auth::WebsocketAuthPolicy;
use super::auth::authorize_upgrade;
use super::auth::is_unauthenticated_non_loopback_listener;
use super::forward_incoming_message;
use super::next_connection_id;
use super::serialize_outgoing_message;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::QueuedOutgoingMessage;
use axum::Router;
use axum::body::Body;
use axum::body::Bytes;
use axum::extract::ConnectInfo;
use axum::extract::State;
use axum::extract::ws::Message as AxumWebSocketMessage;
use axum::extract::ws::WebSocketUpgrade;
use axum::http::HeaderMap;
use axum::http::Request;
use axum::http::StatusCode;
use axum::http::header::ORIGIN;
use axum::middleware;
use axum::middleware::Next;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::any;
use axum::routing::get;
use futures::SinkExt;
use futures::StreamExt;
use owo_colors::OwoColorize;
use owo_colors::Stream;
use owo_colors::Style;
use std::io::Result as IoResult;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Bytes;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::Message as TungsteniteWebSocketMessage;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::info;
use tracing::warn;
/// WebSocket clients can briefly lag behind normal turn output bursts while the
@@ -19,6 +48,127 @@ use tracing::warn;
const WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY: usize = 32 * 1024;
const _: () = assert!(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY > CHANNEL_CAPACITY);
fn colorize(text: &str, style: Style) -> String {
text.if_supports_color(Stream::Stderr, |value| value.style(style))
.to_string()
}
#[allow(clippy::print_stderr)]
fn print_websocket_startup_banner(addr: SocketAddr) {
let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan());
let listening_label = colorize("listening on:", Style::new().dimmed());
let listen_url = colorize(&format!("ws://{addr}"), Style::new().green());
let ready_label = colorize("readyz:", Style::new().dimmed());
let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green());
let health_label = colorize("healthz:", Style::new().dimmed());
let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green());
let note_label = colorize("note:", Style::new().dimmed());
eprintln!("{title}");
eprintln!(" {listening_label} {listen_url}");
eprintln!(" {ready_label} {ready_url}");
eprintln!(" {health_label} {health_url}");
if addr.ip().is_loopback() {
eprintln!(
" {note_label} binds localhost only (use SSH port-forwarding for remote access)"
);
} else {
eprintln!(" {note_label} websocket auth is required for non-localhost listeners");
}
}
#[derive(Clone)]
struct WebSocketListenerState {
transport_event_tx: mpsc::Sender<TransportEvent>,
auth_policy: Arc<WebsocketAuthPolicy>,
}
async fn health_check_handler() -> StatusCode {
StatusCode::OK
}
async fn reject_requests_with_origin_header(
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
if request.headers().contains_key(ORIGIN) {
warn!(
method = %request.method(),
uri = %request.uri(),
"rejecting websocket listener request with Origin header"
);
Err(StatusCode::FORBIDDEN)
} else {
Ok(next.run(request).await)
}
}
async fn websocket_upgrade_handler(
websocket: WebSocketUpgrade,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
State(state): State<WebSocketListenerState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) {
warn!(
%peer_addr,
message = err.message(),
"rejecting websocket client during upgrade"
);
return (err.status_code(), err.message()).into_response();
}
info!(%peer_addr, "websocket client connected");
websocket
.on_upgrade(move |stream| async move {
let (websocket_writer, websocket_reader) = stream.split();
run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx)
.await;
})
.into_response()
}
pub async fn start_websocket_acceptor(
bind_address: SocketAddr,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
auth_policy: WebsocketAuthPolicy,
) -> IoResult<JoinHandle<()>> {
if is_unauthenticated_non_loopback_listener(bind_address, &auth_policy) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to start non-loopback websocket listener {bind_address} without auth; configure `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
),
));
}
let listener = TcpListener::bind(bind_address).await?;
let local_addr = listener.local_addr()?;
print_websocket_startup_banner(local_addr);
info!("app-server websocket listening on ws://{local_addr}");
let router = Router::new()
.route("/readyz", get(health_check_handler))
.route("/healthz", get(health_check_handler))
.fallback(any(websocket_upgrade_handler))
.layer(middleware::from_fn(reject_requests_with_origin_header))
.with_state(WebSocketListenerState {
transport_event_tx,
auth_policy: Arc::new(auth_policy),
});
let server = axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
shutdown_token.cancelled().await;
});
Ok(tokio::spawn(async move {
if let Err(err) = server.await {
error!("websocket acceptor failed: {err}");
}
info!("websocket acceptor shutting down");
}))
}
pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
websocket_writer: impl futures::sink::Sink<M, Error = SinkError> + Send + 'static,
websocket_reader: impl futures::stream::Stream<Item = Result<M, StreamError>> + Send + 'static,
@@ -36,7 +186,7 @@ pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
if transport_event_tx
.send(TransportEvent::ConnectionOpened {
connection_id,
origin: ConnectionOrigin::UnixSocket,
origin: ConnectionOrigin::WebSocket,
writer: writer_tx,
disconnect_sender: Some(disconnect_token.clone()),
})
@@ -95,6 +245,26 @@ pub(crate) trait AppServerWebSocketMessage: Sized {
fn into_incoming(self) -> Option<IncomingWebSocketMessage>;
}
impl AppServerWebSocketMessage for AxumWebSocketMessage {
fn text(text: String) -> Self {
Self::Text(text.into())
}
fn pong(payload: Bytes) -> Self {
Self::Pong(payload)
}
fn into_incoming(self) -> Option<IncomingWebSocketMessage> {
Some(match self {
Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()),
Self::Binary(_) => IncomingWebSocketMessage::Binary,
Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload),
Self::Pong(_) => IncomingWebSocketMessage::Pong,
Self::Close(_) => IncomingWebSocketMessage::Close,
})
}
}
impl AppServerWebSocketMessage for TungsteniteWebSocketMessage {
fn text(text: String) -> Self {
Self::Text(text.into())
+4 -1
View File
@@ -28,6 +28,7 @@ axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
"ws",
] }
codex-analytics = { workspace = true }
codex-arg0 = { workspace = true }
@@ -100,20 +101,22 @@ axum = { workspace = true, default-features = false, features = [
"tokio",
] }
base64 = { workspace = true }
codex-uds = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
flate2 = { workspace = true }
hmac = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
rmcp = { workspace = true, default-features = false, features = [
"elicitation",
"server",
"transport-streamable-http-server",
] }
serial_test = { workspace = true }
sha2 = { workspace = true }
shlex = { workspace = true }
tar = { workspace = true }
tokio-tungstenite = { workspace = true }
+12 -1
View File
@@ -24,9 +24,18 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
Supported transports:
- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL)
- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**)
- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake
- off (`--listen off`): do not expose a local transport
When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes:
- `GET /readyz` returns `200 OK` once the listener is accepting new connections.
- `GET /healthz` returns `200 OK` when no `Origin` header is present.
- Any request carrying an `Origin` header is rejected with `403 Forbidden`.
Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads.
The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy`
opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock`
by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout.
@@ -210,7 +219,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1537,6 +1546,8 @@ To enable or disable a skill by name:
Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response.
For linked Git worktrees, project hook declarations come from the matching `.codex/` folders in the root checkout rather than from divergent hook declarations stored only in the linked worktree. This keeps each repo on one authoritative project-hook definition and one trust state.
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
@@ -1,7 +1,7 @@
//! Tracing helpers shared by socket and in-process app-server entry points.
//!
//! The in-process path intentionally reuses the same span shape as JSON-RPC
//! transports so request telemetry stays comparable across stdio, unix socket,
//! transports so request telemetry stays comparable across stdio, websocket,
//! and embedded callers. [`typed_request_span`] is the in-process counterpart
//! of [`request_span`] and stamps `rpc.transport` as `"in-process"` while
//! deriving client identity from the typed [`ClientRequest`] rather than
@@ -86,6 +86,7 @@ fn transport_name(transport: &AppServerTransport) -> &'static str {
match transport {
AppServerTransport::Stdio => "stdio",
AppServerTransport::UnixSocket { .. } => "unix_socket",
AppServerTransport::WebSocket { .. } => "websocket",
AppServerTransport::Off => "off",
}
}
+9 -1
View File
@@ -30,6 +30,7 @@ pub(crate) struct ConfigManager {
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
@@ -40,6 +41,7 @@ impl ConfigManager {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
@@ -49,6 +51,7 @@ impl ConfigManager {
cli_overrides: Arc::new(RwLock::new(cli_overrides)),
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
loader_overrides,
strict_config,
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
arg0_paths,
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
@@ -217,6 +220,7 @@ impl ConfigManager {
.codex_home(self.codex_home.clone())
.cli_overrides(merged_cli_overrides)
.loader_overrides(self.loader_overrides.clone())
.strict_config(self.strict_config)
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
@@ -245,7 +249,10 @@ impl ConfigManager {
&self.codex_home,
cwd,
&self.current_cli_overrides(),
self.loader_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: self.loader_overrides.clone(),
strict_config: self.strict_config,
},
self.current_cloud_requirements(),
thread_config_loader.as_ref(),
)
@@ -280,6 +287,7 @@ impl ConfigManager {
codex_home,
cli_overrides,
loader_overrides,
/*strict_config*/ false,
cloud_requirements,
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
+4
View File
@@ -119,6 +119,8 @@ pub struct InProcessStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Loader used to fetch typed thread config sources before a thread starts.
@@ -409,6 +411,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
args.config.codex_home.to_path_buf(),
args.cli_overrides,
args.loader_overrides,
args.strict_config,
args.cloud_requirements,
args.arg0_paths.clone(),
args.thread_config_loader,
@@ -765,6 +768,7 @@ mod tests {
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+32 -26
View File
@@ -32,10 +32,12 @@ use crate::transport::ConnectionState;
use crate::transport::OutboundConnectionState;
use crate::transport::RemoteControlStartConfig;
use crate::transport::TransportEvent;
use crate::transport::auth::policy_from_settings;
use crate::transport::route_outgoing_envelope;
use crate::transport::start_control_socket_acceptor;
use crate::transport::start_remote_control;
use crate::transport::start_stdio_connection;
use crate::transport::start_websocket_acceptor;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigWarningNotification;
@@ -102,6 +104,9 @@ pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
pub use crate::error_code::INVALID_PARAMS_ERROR_CODE;
pub use crate::transport::AppServerTransport;
pub use crate::transport::app_server_control_socket_path;
pub use crate::transport::auth::AppServerWebsocketAuthArgs;
pub use crate::transport::auth::AppServerWebsocketAuthSettings;
pub use crate::transport::auth::WebsocketAuthCliMode;
const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT";
const OTEL_SERVICE_NAME: &str = "codex-app-server";
@@ -123,7 +128,7 @@ fn configured_thread_config_loader(config: &Config) -> Arc<dyn ThreadConfigLoade
/// Control-plane messages from the processor/transport side to the outbound router task.
///
/// `run_main_with_transport` now uses two loops/tasks:
/// `run_main_with_transport_options` uses two loops/tasks:
/// - processor loop: handles incoming JSON-RPC and request dispatch
/// - outbound loop: performs potentially slow writes to per-connection writers
///
@@ -370,15 +375,19 @@ pub async fn run_main(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
default_analytics_enabled: bool,
) -> IoResult<()> {
run_main_with_transport(
run_main_with_transport_options(
arg0_paths,
cli_config_overrides,
loader_overrides,
strict_config,
default_analytics_enabled,
AppServerTransport::Stdio,
SessionSource::VSCode,
AppServerWebsocketAuthSettings::default(),
AppServerRuntimeOptions::default(),
)
.await
}
@@ -402,34 +411,16 @@ impl Default for AppServerRuntimeOptions {
}
}
pub async fn run_main_with_transport(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
) -> IoResult<()> {
run_main_with_transport_options(
arg0_paths,
cli_config_overrides,
loader_overrides,
default_analytics_enabled,
transport,
session_source,
AppServerRuntimeOptions::default(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_main_with_transport_options(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
auth: AppServerWebsocketAuthSettings,
runtime_options: AppServerRuntimeOptions,
) -> IoResult<()> {
let (transport_event_tx, mut transport_event_rx) =
@@ -462,6 +453,7 @@ pub async fn run_main_with_transport_options(
codex_home.to_path_buf(),
cli_kv_overrides.clone(),
loader_overrides,
strict_config,
Default::default(),
arg0_paths.clone(),
Arc::new(NoopThreadConfigLoader),
@@ -490,6 +482,10 @@ pub async fn run_main_with_transport_options(
{
Ok(config) => (config, true),
Err(err) => {
if strict_config {
return Err(err);
}
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
(
@@ -669,6 +665,16 @@ pub async fn run_main_with_transport_options(
.await?;
transport_accept_handles.push(accept_handle);
}
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);
}
AppServerTransport::Off => {}
}
@@ -740,7 +746,7 @@ pub async fn run_main_with_transport_options(
}
OutboundControlEvent::DisconnectAll => {
info!(
"disconnecting {} outbound connection(s) for graceful restart",
"disconnecting {} outbound websocket connection(s) for graceful restart",
outbound_connections.len()
);
for connection_state in outbound_connections.values() {
@@ -1068,9 +1074,9 @@ pub async fn run_main_with_transport_options(
fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport {
match transport {
AppServerTransport::Stdio => AppServerRpcTransport::Stdio,
AppServerTransport::UnixSocket { .. } | AppServerTransport::Off => {
AppServerRpcTransport::Websocket
}
AppServerTransport::UnixSocket { .. }
| AppServerTransport::WebSocket { .. }
| AppServerTransport::Off => AppServerRpcTransport::Websocket,
}
}
+22 -5
View File
@@ -1,6 +1,7 @@
use clap::Parser;
use codex_app_server::AppServerRuntimeOptions;
use codex_app_server::AppServerTransport;
use codex_app_server::AppServerWebsocketAuthArgs;
use codex_app_server::PluginStartupTasks;
use codex_app_server::run_main_with_transport_options;
use codex_arg0::Arg0DispatchPaths;
@@ -18,7 +19,7 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C
#[derive(Debug, Parser)]
struct AppServerArgs {
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `unix://`, `unix://PATH`, `off`.
/// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`.
#[arg(
long = "listen",
value_name = "URL",
@@ -35,6 +36,13 @@ struct AppServerArgs {
)]
session_source: SessionSource,
#[command(flatten)]
auth: AppServerWebsocketAuthArgs,
/// Fail if config.toml contains unknown configuration fields.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
/// Hidden debug-only test hook used by integration tests that spawn the
/// production app-server binary.
#[cfg(debug_assertions)]
@@ -44,7 +52,14 @@ struct AppServerArgs {
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
let args = AppServerArgs::parse();
let AppServerArgs {
listen,
session_source,
auth,
strict_config,
#[cfg(debug_assertions)]
disable_plugin_startup_tasks_for_tests,
} = AppServerArgs::parse();
let loader_overrides = if disable_managed_config_from_debug_env() {
LoaderOverrides::without_managed_config_for_tests()
} else {
@@ -52,11 +67,11 @@ fn main() -> anyhow::Result<()> {
.map(LoaderOverrides::with_managed_config_path_for_tests)
.unwrap_or_default()
};
let transport = args.listen;
let session_source = args.session_source;
let transport = listen;
let auth = auth.try_into_settings()?;
let mut runtime_options = AppServerRuntimeOptions::default();
#[cfg(debug_assertions)]
if args.disable_plugin_startup_tasks_for_tests {
if disable_plugin_startup_tasks_for_tests {
runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip;
}
@@ -64,9 +79,11 @@ fn main() -> anyhow::Result<()> {
arg0_paths,
CliConfigOverrides::default(),
loader_overrides,
strict_config,
/*default_analytics_enabled*/ false,
transport,
session_source,
auth,
runtime_options,
)
.await?;
+1
View File
@@ -207,6 +207,7 @@ mod tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
loader.clone(),
@@ -1103,6 +1103,9 @@ impl MessageProcessor {
ClientRequest::PluginShareList { params, .. } => {
self.plugin_processor.plugin_share_list(params).await
}
ClientRequest::PluginShareCheckout { params, .. } => {
self.plugin_processor.plugin_share_checkout(params).await
}
ClientRequest::PluginShareDelete { params, .. } => {
self.plugin_processor.plugin_share_delete(params).await
}
@@ -239,6 +239,7 @@ async fn build_test_processor(
config.codex_home.to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
@@ -115,6 +115,8 @@ use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareCheckoutParams;
use codex_app_server_protocol::PluginShareCheckoutResponse;
use codex_app_server_protocol::PluginShareContext;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
@@ -401,7 +403,6 @@ use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
use codex_thread_store::ThreadSortKey as StoreThreadSortKey;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::HashMap;
@@ -2,6 +2,8 @@ use super::*;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
// The override is intentionally available only in debug builds, matching the login path below.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
enum ActiveLogin {
@@ -525,6 +525,7 @@ impl CatalogRequestProcessor {
};
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: config.features.enabled(Feature::CodexHooks),
bypass_hook_trust: config.bypass_hook_trust,
config_layer_stack: Some(config.config_layer_stack),
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
@@ -449,6 +449,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
}
normalized
}),
allow_managed_hooks_only: requirements.allow_managed_hooks_only,
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
@@ -631,3 +632,21 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
}));
error
}
#[cfg(test)]
mod tests {
use super::map_requirements_toml_to_api;
use codex_config::ConfigRequirementsToml;
use pretty_assertions::assert_eq;
#[test]
fn requirements_api_includes_allow_managed_hooks_only() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
allow_managed_hooks_only: Some(true),
..ConfigRequirementsToml::default()
});
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
assert_eq!(mapped.hooks, None);
}
}
@@ -323,7 +323,7 @@ impl ExternalAgentConfigRequestProcessor {
.thread
.update_thread_metadata(
ThreadMetadataPatch {
name: Some(name),
name: Some(Some(name)),
..Default::default()
},
/*include_archived*/ false,
@@ -111,6 +111,7 @@ fn share_context_for_source(
.cloned()
.map(|remote_plugin_id| PluginShareContext {
remote_plugin_id,
remote_version: None,
discoverability: None,
share_url: None,
creator_account_user_id: None,
@@ -312,6 +313,15 @@ impl PluginRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_checkout(
&self,
params: PluginShareCheckoutParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_checkout_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_delete(
&self,
params: PluginShareDeleteParams,
@@ -483,6 +493,7 @@ impl PluginRequestProcessor {
PluginSummary {
id: plugin.id,
remote_plugin_id: None,
local_version: plugin.local_version,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
@@ -533,7 +544,9 @@ impl PluginRequestProcessor {
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) {
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe)
&& config.features.enabled(Feature::PluginSharing)
{
remote_sources.push(RemoteMarketplaceSource::SharedWithMe);
}
if !remote_sources.is_empty() {
@@ -673,17 +686,21 @@ impl PluginRequestProcessor {
)
.await
{
Ok(Some(remote_share_context))
if remote_share_context.share_principals.is_some() =>
{
Some(remote_plugin_share_context_to_info(remote_share_context))
}
Ok(Some(_)) => {
warn!(
remote_plugin_id = %context.remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context"
);
Some(context)
Ok(Some(remote_share_context)) => {
if remote_share_context.share_principals.is_some() {
Some(remote_plugin_share_context_to_info(remote_share_context))
} else {
let remote_version = remote_share_context.remote_version;
let remote_plugin_id = context.remote_plugin_id.clone();
warn!(
remote_plugin_id = %remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context with remote version"
);
Some(PluginShareContext {
remote_version,
..context
})
}
}
Ok(None) => {
warn!(
@@ -725,6 +742,7 @@ impl PluginRequestProcessor {
summary: PluginSummary {
id: outcome.plugin.id,
remote_plugin_id: None,
local_version: outcome.plugin.local_version,
name: outcome.plugin.name,
share_context,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
@@ -840,6 +858,9 @@ impl PluginRequestProcessor {
params: PluginShareSaveParams,
) -> Result<PluginShareSaveResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
@@ -961,6 +982,42 @@ impl PluginRequestProcessor {
Ok(PluginShareListResponse { data })
}
async fn plugin_share_checkout_response(
&self,
params: PluginShareCheckoutParams,
) -> Result<PluginShareCheckoutResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareCheckoutParams { remote_plugin_id } = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::checkout_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "checkout plugin share"))?;
self.clear_plugin_related_caches();
Ok(PluginShareCheckoutResponse {
remote_plugin_id: result.remote_plugin_id,
plugin_id: result.plugin_id,
plugin_name: result.plugin_name,
plugin_path: result.plugin_path,
marketplace_name: result.marketplace_name,
marketplace_path: result.marketplace_path,
remote_version: result.remote_version,
})
}
async fn plugin_share_delete_response(
&self,
params: PluginShareDeleteParams,
@@ -1589,6 +1646,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
PluginSummary {
id: summary.id,
remote_plugin_id: Some(summary.remote_plugin_id),
local_version: None,
name: summary.name,
share_context: summary
.share_context
@@ -1609,6 +1667,7 @@ fn remote_plugin_share_context_to_info(
) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: context.remote_plugin_id,
remote_version: context.remote_version,
discoverability: Some(remote_plugin_share_discoverability_to_info(
context.discoverability,
)),
@@ -1680,6 +1739,7 @@ fn remote_plugin_catalog_error_to_jsonrpc(
invalid_request(message)
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message),
RemotePluginCatalogError::AuthToken(_)
@@ -1430,17 +1430,17 @@ impl ThreadRequestProcessor {
};
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
self.thread_store
.update_thread_metadata(StoreUpdateThreadMetadataParams {
self.thread_manager
.update_thread_metadata(
thread_id,
patch: StoreThreadMetadataPatch {
name: Some(name.clone()),
StoreThreadMetadataPatch {
name: Some(Some(name.clone())),
..Default::default()
},
include_archived: false,
})
/*include_archived*/ false,
)
.await
.map_err(|err| thread_store_write_error("set thread name", err))?;
.map_err(|err| core_thread_write_error("set thread name", err))?;
Ok((
ThreadSetNameResponse {},
@@ -1459,33 +1459,17 @@ impl ThreadRequestProcessor {
let thread_id = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
if thread.config_snapshot().await.ephemeral {
return Err(invalid_request(format!(
"ephemeral thread does not support memory mode updates: {thread_id}"
)));
}
thread
.set_thread_memory_mode(mode.to_core())
.await
.map_err(|err| {
internal_error(format!("failed to set thread memory mode: {err}"))
})?;
return Ok(ThreadMemoryModeSetResponse {});
}
self.thread_store
.update_thread_metadata(StoreUpdateThreadMetadataParams {
self.thread_manager
.update_thread_metadata(
thread_id,
patch: StoreThreadMetadataPatch {
StoreThreadMetadataPatch {
memory_mode: Some(mode.to_core()),
..Default::default()
},
include_archived: false,
})
/*include_archived*/ false,
)
.await
.map_err(|err| thread_store_write_error("set thread memory mode", err))?;
.map_err(|err| core_thread_write_error("set thread memory mode", err))?;
Ok(ThreadMemoryModeSetResponse {})
}
@@ -1551,35 +1535,19 @@ impl ThreadRequestProcessor {
..Default::default()
};
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let updated_thread = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
if let Some(loaded_thread) = loaded_thread.as_ref() {
if loaded_thread.config_snapshot().await.ephemeral {
return Err(invalid_request(format!(
"ephemeral thread does not support metadata updates: {thread_id}"
)));
}
loaded_thread
.update_thread_metadata(patch, /*include_archived*/ true)
.await
} else {
self.thread_store
.update_thread_metadata(StoreUpdateThreadMetadataParams {
thread_id: thread_uuid,
patch,
include_archived: true,
})
.await
}
.map_err(|err| thread_store_write_error("update thread metadata", err))?
self.thread_manager
.update_thread_metadata(thread_uuid, patch, /*include_archived*/ true)
.await
.map_err(|err| core_thread_write_error("update thread metadata", err))?
};
let (mut thread, _) = thread_from_stored_thread(
updated_thread,
self.config.model_provider_id.as_str(),
&self.config.cwd,
);
if let Some(loaded_thread) = loaded_thread.as_ref() {
if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await {
thread.session_id = loaded_thread.session_configured().session_id.to_string();
}
self.attach_thread_name(thread_uuid, &mut thread).await;
@@ -3707,15 +3675,13 @@ fn conversation_summary_rollout_path_read_error(
}
}
fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError {
fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id) => {
invalid_request(format!("thread not found: {thread_id}"))
}
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
CodexErr::InvalidRequest(message) => invalid_request(message),
CodexErr::UnsupportedOperation(message) => method_not_found(message),
err => internal_error(format!("failed to {operation}: {err}")),
}
}
@@ -582,6 +582,7 @@ mod thread_processor_behavior_tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(StaticThreadConfigLoader::new(vec![
+2
View File
@@ -22,9 +22,11 @@ pub(crate) use codex_app_server_transport::RemoteControlHandle;
pub(crate) use codex_app_server_transport::RemoteControlStartConfig;
pub(crate) use codex_app_server_transport::TransportEvent;
pub use codex_app_server_transport::app_server_control_socket_path;
pub use codex_app_server_transport::auth;
pub(crate) use codex_app_server_transport::start_control_socket_acceptor;
pub(crate) use codex_app_server_transport::start_remote_control;
pub(crate) use codex_app_server_transport::start_stdio_connection;
pub(crate) use codex_app_server_transport::start_websocket_acceptor;
pub(crate) struct ConnectionState {
pub(crate) outbound_initialized: Arc<AtomicBool>,
@@ -149,6 +149,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+1
View File
@@ -1,4 +1,5 @@
mod auth;
mod conversation_summary;
mod fuzzy_file_search;
mod strict_config;
mod v2;
@@ -0,0 +1,33 @@
use std::process::Command;
use anyhow::Result;
use tempfile::TempDir;
#[test]
fn strict_config_rejects_unknown_config_fields_for_standalone_app_server() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
foo = "bar"
"#,
)?;
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?)
.env("CODEX_HOME", codex_home.path())
.env(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
codex_home.path().join("managed_config.toml"),
)
.args(["--strict-config", "--listen", "off"])
.output()?;
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr)?;
assert!(
stderr.contains("unknown configuration field `foo`"),
"expected strict config error in stderr, got: {stderr}"
);
Ok(())
}
@@ -861,10 +861,10 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
.as_nanos()
);
let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut ws1 = connect_websocket(&socket_path).await?;
let mut ws2 = connect_websocket(&socket_path).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?;
read_initialize_response(&mut ws1, /*request_id*/ 1).await?;
@@ -929,7 +929,7 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
process
.kill()
.await
.context("failed to stop app-server process")?;
.context("failed to stop websocket app-server process")?;
Ok(())
}
@@ -4,6 +4,8 @@ use anyhow::bail;
use app_test_support::DISABLE_PLUGIN_STARTUP_TASKS_ARG;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
@@ -16,14 +18,18 @@ use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_uds::UnixStream;
use futures::SinkExt;
use futures::StreamExt;
use hmac::Hmac;
use hmac::Mac;
use reqwest::StatusCode;
use serde_json::json;
use sha2::Sha256;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use tempfile::TempDir;
use time::OffsetDateTime;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
@@ -32,29 +38,37 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::client_async;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::tungstenite::http::header::ORIGIN;
// macOS and Windows CI can spend tens of seconds starting the app-server test
// binary under Bazel before it accepts JSON-RPC over the control socket.
// binary under Bazel before it accepts JSON-RPC or reports its websocket bind
// address.
#[cfg(any(target_os = "macos", windows))]
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) type WsClient = WebSocketStream<UnixStream>;
pub(super) type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
type HmacSha256 = Hmac<Sha256>;
#[tokio::test]
async fn unix_socket_transport_routes_per_connection_handshake_and_responses() -> Result<()> {
async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut ws1 = connect_websocket(&socket_path).await?;
let mut ws2 = connect_websocket(&socket_path).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?;
let first_init = read_response_for_id(&mut ws1, /*id*/ 1).await?;
@@ -86,20 +100,259 @@ async fn unix_socket_transport_routes_per_connection_handshake_and_responses() -
process
.kill()
.await
.context("failed to stop app-server process")?;
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout()
-> Result<()> {
async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let client = reqwest::Client::new();
let mut ws1 = connect_websocket(&socket_path).await?;
let readyz = http_get(&client, bind_addr, "/readyz").await?;
assert_eq!(readyz.status(), StatusCode::OK);
let healthz = http_get(&client, bind_addr, "/healthz").await?;
assert_eq!(healthz.status(), StatusCode::OK);
let mut ws = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws, /*id*/ 1, "ws_health_client").await?;
let init = read_response_for_id(&mut ws, /*id*/ 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_rejects_browser_origin_without_auth() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut ws = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws, /*id*/ 1, "ws_loopback_client").await?;
let init = read_response_for_id(&mut ws, /*id*/ 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
drop(ws);
assert_websocket_connect_rejected_with_headers(
bind_addr,
/*bearer_token*/ None,
Some("https://evil.example"),
StatusCode::FORBIDDEN,
)
.await?;
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let token_file = codex_home.path().join("app-server-token");
std::fs::write(&token_file, "super-secret-token\n")?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let auth_args = vec![
"--ws-auth".to_string(),
"capability-token".to_string(),
"--ws-token-file".to_string(),
token_file.display().to_string(),
];
let (mut process, bind_addr) =
spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &auth_args).await?;
assert_websocket_connect_rejected(bind_addr, /*bearer_token*/ None).await?;
assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?;
let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?;
send_initialize_request(&mut ws, /*id*/ 1, "ws_auth_client").await?;
let init = read_response_for_id(&mut ws, /*id*/ 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let shared_secret_file = codex_home.path().join("app-server-signing-secret");
let shared_secret = "0123456789abcdef0123456789abcdef";
std::fs::write(&shared_secret_file, format!("{shared_secret}\n"))?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let auth_args = vec![
"--ws-auth".to_string(),
"signed-bearer-token".to_string(),
"--ws-shared-secret-file".to_string(),
shared_secret_file.display().to_string(),
"--ws-issuer".to_string(),
"codex-enroller".to_string(),
"--ws-audience".to_string(),
"codex-app-server".to_string(),
"--ws-max-clock-skew-seconds".to_string(),
"1".to_string(),
];
let (mut process, bind_addr) =
spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?;
let expired_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() - 30,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(expired_token.as_str())).await?;
let malformed_token = "not-a-jwt";
assert_websocket_connect_rejected(bind_addr, Some(malformed_token)).await?;
let not_yet_valid_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"nbf": OffsetDateTime::now_utc().unix_timestamp() + 30,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(not_yet_valid_token.as_str())).await?;
let wrong_issuer_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "someone-else",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(wrong_issuer_token.as_str())).await?;
let wrong_audience_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "codex-enroller",
"aud": "wrong-audience",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(wrong_audience_token.as_str())).await?;
let wrong_signature_token = signed_bearer_token(
b"fedcba9876543210fedcba9876543210",
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(wrong_signature_token.as_str())).await?;
let valid_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?;
send_initialize_request(&mut ws, /*id*/ 1, "ws_signed_auth_client").await?;
let init = read_response_for_id(&mut ws, /*id*/ 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let shared_secret_file = codex_home.path().join("app-server-signing-secret");
std::fs::write(&shared_secret_file, "too-short\n")?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let output = run_websocket_server_to_completion_with_args(
codex_home.path(),
"ws://127.0.0.1:0",
&[
"--ws-auth".to_string(),
"signed-bearer-token".to_string(),
"--ws-shared-secret-file".to_string(),
shared_secret_file.display().to_string(),
],
)
.await?;
assert!(
!output.status.success(),
"short shared secret should fail websocket server startup"
);
let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?;
assert!(
stderr.contains("must be at least 32 bytes"),
"unexpected stderr: {stderr}"
);
Ok(())
}
#[tokio::test]
async fn websocket_transport_rejects_unauthenticated_non_loopback_startup() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let output =
run_websocket_server_to_completion_with_args(codex_home.path(), "ws://0.0.0.0:0", &[])
.await?;
assert!(
!output.status.success(),
"unauthenticated non-loopback listener should fail websocket server startup"
);
let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?;
assert!(
stderr.contains("refusing to start non-loopback websocket listener"),
"unexpected stderr: {stderr}"
);
Ok(())
}
#[tokio::test]
async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() -> Result<()>
{
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws1, /*id*/ 1, "ws_thread_owner").await?;
read_response_for_id(&mut ws1, /*id*/ 1).await?;
@@ -109,7 +362,7 @@ async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_t
ws1.close(None).await.context("failed to close websocket")?;
drop(ws1);
let mut ws2 = connect_websocket(&socket_path).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws2, /*id*/ 4, "ws_reconnect_client").await?;
read_response_for_id(&mut ws2, /*id*/ 4).await?;
@@ -118,29 +371,26 @@ async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_t
process
.kill()
.await
.context("failed to stop app-server process")?;
.context("failed to stop websocket app-server process")?;
Ok(())
}
pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, PathBuf, TempDir)> {
pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> {
spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await
}
pub(super) async fn spawn_websocket_server_with_args(
codex_home: &Path,
listen_url: &str,
extra_args: &[String],
) -> Result<(Child, SocketAddr)> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find app-server binary")?;
#[cfg(unix)]
let socket_dir = tempfile::Builder::new()
.prefix("cxs-")
.tempdir_in("/tmp")
.context("failed to create short app-server socket temp dir")?;
#[cfg(not(unix))]
let socket_dir = tempfile::Builder::new()
.prefix("cxs-")
.tempdir()
.context("failed to create app-server socket temp dir")?;
let socket_path = socket_dir.path().join("c.sock");
let listen_url = format!("unix://{}", socket_path.display());
let mut cmd = Command::new(program);
cmd.arg("--listen")
.arg(&listen_url)
.arg(listen_url)
.arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG)
.args(extra_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
@@ -149,62 +399,194 @@ pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child,
let mut process = cmd
.kill_on_drop(true)
.spawn()
.context("failed to spawn app-server process")?;
.context("failed to spawn websocket app-server process")?;
let stderr = process
.stderr
.take()
.context("failed to capture app-server stderr")?;
.context("failed to capture websocket app-server stderr")?;
let mut stderr_reader = BufReader::new(stderr).lines();
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let bind_addr = loop {
let line = timeout(
deadline.saturating_duration_since(Instant::now()),
stderr_reader.next_line(),
)
.await
.context("timed out waiting for websocket app-server to report bound websocket address")?
.context("failed to read websocket app-server stderr")?
.context("websocket app-server exited before reporting bound websocket address")?;
eprintln!("[websocket app-server stderr] {line}");
let stripped_line = {
let mut stripped = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' && matches!(chars.peek(), Some(&'[')) {
chars.next();
for next in chars.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
continue;
}
stripped.push(ch);
}
stripped
};
if let Some(bind_addr) = stripped_line
.split_whitespace()
.find_map(|token| token.strip_prefix("ws://"))
.and_then(|addr| addr.parse::<SocketAddr>().ok())
{
break bind_addr;
}
};
tokio::spawn(async move {
while let Ok(Some(line)) = stderr_reader.next_line().await {
eprintln!("[app-server stderr] {line}");
eprintln!("[websocket app-server stderr] {line}");
}
});
Ok((process, bind_addr))
}
pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result<WsClient> {
connect_websocket_with_bearer(bind_addr, /*bearer_token*/ None).await
}
pub(super) async fn connect_websocket_with_bearer(
bind_addr: SocketAddr,
bearer_token: Option<&str>,
) -> Result<WsClient> {
let url = format!("ws://{}", connectable_bind_addr(bind_addr));
let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?;
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
if socket_path.exists() {
return Ok((process, socket_path, socket_dir));
match connect_async(request.clone()).await {
Ok((stream, _response)) => return Ok(stream),
Err(err) => {
if Instant::now() >= deadline {
bail!("failed to connect websocket to {url}: {err}");
}
sleep(Duration::from_millis(50)).await;
}
}
if let Some(status) = process.try_wait()? {
bail!("app-server exited before creating control socket: {status}");
}
if Instant::now() >= deadline {
bail!(
"timed out waiting for app-server control socket at {}",
socket_path.display()
);
}
sleep(Duration::from_millis(50)).await;
}
}
pub(super) async fn connect_websocket(socket_path: &Path) -> Result<WsClient> {
async fn assert_websocket_connect_rejected(
bind_addr: SocketAddr,
bearer_token: Option<&str>,
) -> Result<()> {
assert_websocket_connect_rejected_with_headers(
bind_addr,
bearer_token,
/*origin*/ None,
StatusCode::UNAUTHORIZED,
)
.await
}
async fn assert_websocket_connect_rejected_with_headers(
bind_addr: SocketAddr,
bearer_token: Option<&str>,
origin: Option<&str>,
expected_status: StatusCode,
) -> Result<()> {
let url = format!("ws://{}", connectable_bind_addr(bind_addr));
let request = websocket_request(url.as_str(), bearer_token, origin)?;
match connect_async(request).await {
Ok((_stream, response)) => {
bail!(
"expected websocket handshake rejection, got {}",
response.status()
)
}
Err(WsError::Http(response)) => {
assert_eq!(response.status(), expected_status);
Ok(())
}
Err(err) => bail!("expected http rejection during websocket handshake: {err}"),
}
}
async fn run_websocket_server_to_completion_with_args(
codex_home: &Path,
listen_url: &str,
extra_args: &[String],
) -> Result<std::process::Output> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find app-server binary")?;
let mut cmd = Command::new(program);
cmd.arg("--listen")
.arg(listen_url)
.arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG)
.args(extra_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env("CODEX_HOME", codex_home)
.env("RUST_LOG", "warn");
timeout(DEFAULT_READ_TIMEOUT, cmd.output())
.await
.context("timed out waiting for websocket app-server to exit")?
.context("failed to run websocket app-server")
}
async fn http_get(
client: &reqwest::Client,
bind_addr: SocketAddr,
path: &str,
) -> Result<reqwest::Response> {
let connectable_bind_addr = connectable_bind_addr(bind_addr);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
match UnixStream::connect(socket_path).await {
Ok(stream) => match client_async("ws://localhost/rpc", stream).await {
Ok((websocket, _response)) => return Ok(websocket),
Err(err) => {
if Instant::now() >= deadline {
bail!(
"failed to upgrade websocket over {}: {err}",
socket_path.display()
);
}
}
},
match client
.get(format!("http://{connectable_bind_addr}{path}"))
.send()
.await
.with_context(|| format!("failed to GET http://{connectable_bind_addr}{path}"))
{
Ok(response) => return Ok(response),
Err(err) => {
if Instant::now() >= deadline {
bail!("failed to connect to {}: {err}", socket_path.display());
bail!("failed to GET http://{connectable_bind_addr}{path}: {err}");
}
sleep(Duration::from_millis(50)).await;
}
}
sleep(Duration::from_millis(50)).await;
}
}
fn websocket_request(
url: &str,
bearer_token: Option<&str>,
origin: Option<&str>,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>> {
let mut request = url
.into_client_request()
.context("failed to create websocket request")?;
if let Some(bearer_token) = bearer_token {
request.headers_mut().insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {bearer_token}"))
.context("invalid bearer token header")?,
);
}
if let Some(origin) = origin {
request.headers_mut().insert(
ORIGIN,
HeaderValue::from_str(origin).context("invalid origin header")?,
);
}
Ok(request)
}
pub(super) async fn send_initialize_request(
stream: &mut WsClient,
id: i64,
@@ -467,3 +849,25 @@ stream_max_retries = 0
),
)
}
fn connectable_bind_addr(bind_addr: SocketAddr) -> SocketAddr {
match bind_addr {
SocketAddr::V4(addr) if addr.ip().is_unspecified() => {
SocketAddr::from(([127, 0, 0, 1], addr.port()))
}
SocketAddr::V6(addr) if addr.ip().is_unspecified() => {
SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], addr.port()))
}
_ => bind_addr,
}
}
fn signed_bearer_token(shared_secret: &[u8], claims: serde_json::Value) -> Result<String> {
let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?);
let payload = format!("{header_segment}.{claims_segment}");
let mut mac = HmacSha256::new_from_slice(shared_secret).context("failed to create hmac")?;
mac.update(payload.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Ok(format!("{payload}.{signature}"))
}
@@ -32,13 +32,12 @@ use wiremock::matchers::method;
use wiremock::matchers::path_regex;
#[tokio::test]
async fn unix_socket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> {
async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> {
let GracefulCtrlCFixture {
_codex_home,
_server,
mut process,
mut ws,
..
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
send_sigint(&process)?;
@@ -58,13 +57,12 @@ async fn unix_socket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Re
}
#[tokio::test]
async fn unix_socket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> {
async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> {
let GracefulCtrlCFixture {
_codex_home,
_server,
mut process,
mut ws,
..
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
send_sigint(&process)?;
@@ -85,13 +83,12 @@ async fn unix_socket_transport_second_ctrl_c_forces_exit_while_turn_running() ->
}
#[tokio::test]
async fn unix_socket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> {
async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> {
let GracefulCtrlCFixture {
_codex_home,
_server,
mut process,
mut ws,
..
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
send_sigterm(&process)?;
@@ -111,13 +108,12 @@ async fn unix_socket_transport_sigterm_waits_for_running_turn_before_exit() -> R
}
#[tokio::test]
async fn unix_socket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> {
async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> {
let GracefulCtrlCFixture {
_codex_home,
_server,
mut process,
mut ws,
..
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
send_sigterm(&process)?;
@@ -144,7 +140,6 @@ async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() ->
_server,
mut process,
mut ws,
..
} = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?;
send_sighup(&process)?;
@@ -168,7 +163,6 @@ async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() ->
struct GracefulCtrlCFixture {
_codex_home: TempDir,
_socket_dir: TempDir,
_server: wiremock::MockServer,
process: Child,
ws: WsClient,
@@ -187,8 +181,8 @@ async fn start_ctrl_c_restart_fixture(turn_delay: Duration) -> Result<GracefulCt
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (process, socket_path, socket_dir) = spawn_websocket_server(codex_home.path()).await?;
let mut ws = connect_websocket(&socket_path).await?;
let (process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut ws = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws, /*id*/ 1, "ws_graceful_shutdown").await?;
let init_response = read_response_for_id(&mut ws, /*id*/ 1).await?;
@@ -206,7 +200,6 @@ async fn start_ctrl_c_restart_fixture(turn_delay: Duration) -> Result<GracefulCt
Ok(GracefulCtrlCFixture {
_codex_home: codex_home,
_socket_dir: socket_dir,
_server: server,
process,
ws,
@@ -276,7 +269,9 @@ fn send_sighup(process: &Child) -> Result<()> {
}
fn send_signal(process: &Child, signal: &str) -> Result<()> {
let pid = process.id().context("app-server process has no pid")?;
let pid = process
.id()
.context("websocket app-server process has no pid")?;
let status = StdCommand::new("kill")
.arg(signal)
.arg(pid.to_string())
@@ -304,7 +299,7 @@ async fn wait_for_process_exit_within(
timeout(window, process.wait())
.await
.context(timeout_context)?
.context("failed waiting for app-server process exit")
.context("failed waiting for websocket app-server process exit")
}
async fn expect_websocket_disconnect(stream: &mut WsClient) -> Result<()> {
@@ -107,6 +107,29 @@ enabled = true
Ok(())
}
fn write_project_hook_config(dot_codex_folder: &std::path::Path, command: &str) -> Result<()> {
std::fs::create_dir_all(dot_codex_folder)?;
std::fs::write(
dot_codex_folder.join("config.toml"),
format!(
r#"[features]
hooks = true
[hooks]
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "{command}"
timeout = 5
"#
),
)?;
Ok(())
}
#[tokio::test]
async fn hooks_list_shows_discovered_hook() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -368,6 +391,88 @@ timeout = 5
Ok(())
}
#[tokio::test]
async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let repo_root = workspace.path().join("repo");
let worktree_root = workspace.path().join("worktree");
let worktree_git_dir = repo_root.join(".git/worktrees/feature-x");
std::fs::create_dir_all(&worktree_git_dir)?;
std::fs::create_dir_all(&worktree_root)?;
std::fs::write(
worktree_root.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)?;
write_project_hook_config(&repo_root.join(".codex"), "echo root hook")?;
write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?;
set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let list_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![repo_root.clone(), worktree_root.clone()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
let repo_hook = data[0].hooks[0].clone();
let worktree_hook = data[1].hooks[0].clone();
let repo_config_path =
AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?;
assert_eq!(repo_hook.command.as_deref(), Some("echo root hook"));
assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook"));
assert_eq!(repo_hook.key, worktree_hook.key);
assert_eq!(repo_hook.source_path, repo_config_path);
assert_eq!(worktree_hook.source_path, repo_config_path);
let write_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
edits: vec![ConfigEdit {
key_path: "hooks.state".to_string(),
value: serde_json::json!({
repo_hook.key.clone(): {
"trusted_hash": repo_hook.current_hash.clone()
}
}),
merge_strategy: MergeStrategy::Upsert,
}],
file_path: None,
expected_version: None,
reload_user_config: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
)
.await??;
let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;
let list_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![worktree_root],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
assert_eq!(data[0].hooks[0].trust_status, HookTrustStatus::Trusted);
Ok(())
}
#[tokio::test]
async fn config_batch_write_toggles_user_hook() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -200,6 +200,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+136 -12
View File
@@ -241,6 +241,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
plugins: vec![PluginSummary {
id: "valid-plugin@valid-marketplace".to_string(),
remote_plugin_id: None,
local_version: None,
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -531,6 +532,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
local_version: None,
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -565,6 +567,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
local_version: None,
name: "missing-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -660,7 +663,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<(
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
)?;
write_plugin_share_local_path_mapping(
codex_home.path(),
@@ -692,11 +695,13 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<(
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
assert_eq!(plugin.remote_plugin_id, None);
assert_eq!(plugin.local_version.as_deref(), Some("1.2.3"));
let share_context = plugin
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version, None);
assert_eq!(share_context.discoverability, None);
assert_eq!(share_context.share_url, None);
assert_eq!(share_context.creator_account_user_id, None);
@@ -1819,13 +1824,27 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
))?;
shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null;
let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?;
let workspace_installed_body = workspace_remote_plugin_page_body(
"plugins~Plugin_22222222222222222222222222222222",
"shared-linear",
"Shared Linear",
"PRIVATE",
/*enabled*/ Some(true),
);
let mut workspace_installed_body: serde_json::Value =
serde_json::from_str(&workspace_remote_plugin_page_body(
"plugins~Plugin_22222222222222222222222222222222",
"shared-linear",
"Shared Linear",
"PRIVATE",
/*enabled*/ Some(true),
))?;
let unlisted_installed_body: serde_json::Value =
serde_json::from_str(&workspace_remote_plugin_page_body(
"plugins~Plugin_33333333333333333333333333333333",
"unlisted-linear",
"Unlisted Linear",
"UNLISTED",
/*enabled*/ Some(false),
))?;
workspace_installed_body["plugins"]
.as_array_mut()
.expect("installed plugins should be an array")
.push(unlisted_installed_body["plugins"][0].clone());
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
@@ -1846,9 +1865,12 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(response.marketplaces.len(), 1);
let marketplace = &response.marketplaces[0];
assert_eq!(marketplace.name, "shared-with-me");
assert_eq!(response.marketplaces.len(), 2);
let marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "workspace-shared-with-me-private")
.expect("expected private shared-with-me marketplace");
assert_eq!(
marketplace
.interface
@@ -1857,7 +1879,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
Some("Shared with me")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].id, "shared-linear@shared-with-me");
assert_eq!(
marketplace.plugins[0].id,
"shared-linear@workspace-shared-with-me-private"
);
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_22222222222222222222222222222222")
@@ -1873,6 +1898,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
share_context.remote_plugin_id,
"plugins~Plugin_22222222222222222222222222222222"
);
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Private)
@@ -1887,10 +1913,107 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
Some("https://chatgpt.example/plugins/share/share-key-1")
);
assert_eq!(share_context.share_principals, None);
let marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "workspace-shared-with-me-unlisted")
.expect("expected unlisted shared-with-me marketplace");
assert_eq!(
marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Shared with me (unlisted)")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(
marketplace.plugins[0].id,
"unlisted-linear@workspace-shared-with-me-unlisted"
);
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_33333333333333333333333333333333")
);
assert_eq!(marketplace.plugins[0].name, "unlisted-linear");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, false);
let share_context = marketplace.plugins[0]
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(
share_context.remote_plugin_id,
"plugins~Plugin_33333333333333333333333333333333"
);
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Unlisted)
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"chatgpt_base_url = "{}/backend-api/"
[features]
plugins = true
plugin_sharing = false
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(
response,
PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
}
);
wait_for_remote_plugin_request_count(
&server,
"/ps/plugins/workspace/shared",
/*expected_count*/ 0,
)
.await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -2434,6 +2557,7 @@ fn workspace_remote_plugin_page_body(
}}
],
"release": {{
"version": "1.2.3",
"display_name": "{display_name}",
"description": "Track work",
"app_ids": [],
@@ -262,6 +262,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "2.3.4",
"display_name": "Shared Linear",
"description": "Track shared work",
"app_ids": [],
@@ -302,7 +303,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("shared-with-me".to_string()),
remote_marketplace_name: Some("workspace-shared-with-me-private".to_string()),
plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(),
})
.await?;
@@ -314,8 +315,14 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "shared-with-me");
assert_eq!(response.plugin.summary.id, "shared-linear@shared-with-me");
assert_eq!(
response.plugin.marketplace_name,
"workspace-shared-with-me-private"
);
assert_eq!(
response.plugin.summary.id,
"shared-linear@workspace-shared-with-me-private"
);
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_11111111111111111111111111111111")
@@ -330,6 +337,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
share_context.remote_plugin_id,
"plugins~Plugin_11111111111111111111111111111111"
);
assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Private)
@@ -800,7 +808,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
)?;
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
@@ -833,6 +841,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.2.4",
"display_name": "Demo Plugin",
"description": "Shared local plugin",
"app_ids": [],
@@ -865,6 +874,10 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
assert_eq!(
response.plugin.summary.local_version.as_deref(),
Some("1.2.3")
);
let share_context = response
.plugin
.summary
@@ -872,6 +885,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Unlisted)
@@ -905,6 +919,107 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
Ok(())
}
#[tokio::test]
async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_plugin_marketplace(
repo_root.path(),
"codex-curated",
"demo-plugin",
"./demo-plugin",
)?;
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
std::fs::write(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
)?;
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/plugins_123"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "plugins_123",
"name": "demo-plugin",
"scope": "WORKSPACE",
"discoverability": "UNLISTED",
"creator_account_user_id": "user-owner__account-123",
"creator_name": "Owner",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"share_principals": null,
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.2.4",
"display_name": "Demo Plugin",
"description": "Shared local plugin",
"app_ids": [],
"keywords": [],
"interface": {},
"skills": []
}
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: Some(AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)?),
remote_marketplace_name: None,
plugin_name: "demo-plugin".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
assert_eq!(
response.plugin.summary.local_version.as_deref(),
Some("1.2.3")
);
let share_context = response
.plugin
.summary
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));
assert_eq!(share_context.discoverability, None);
assert_eq!(share_context.share_url, None);
assert_eq!(share_context.creator_account_user_id, None);
assert_eq!(share_context.creator_name, None);
assert_eq!(share_context.share_principals, None);
Ok(())
}
#[tokio::test]
async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -941,6 +1056,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() ->
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
assert_eq!(response.plugin.summary.local_version, None);
let share_context = response
.plugin
.summary
@@ -948,6 +1064,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() ->
.as_ref()
.expect("expected share context");
assert_eq!(share_context.remote_plugin_id, "plugins_123");
assert_eq!(share_context.remote_version, None);
assert_eq!(share_context.discoverability, None);
assert_eq!(share_context.share_url, None);
assert_eq!(share_context.creator_account_user_id, None);
@@ -12,6 +12,9 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginShareCheckoutResponse;
use codex_app_server_protocol::PluginShareContext;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareDiscoverability;
@@ -27,6 +30,8 @@ use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use flate2::Compression;
use flate2::write::GzEncoder;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
@@ -41,6 +46,8 @@ use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
#[tokio::test]
async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
@@ -162,8 +169,9 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "demo-plugin@shared-with-me".to_string(),
id: "demo-plugin@workspace-shared-with-me-private".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -322,6 +330,64 @@ async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = TempDir::new()?;
let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api"
[features]
plugins = true
remote_plugin = true
plugin_sharing = false
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
})),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(error.error.message, "plugin sharing is disabled");
assert!(
server
.received_requests()
.await
.expect("wiremock should record requests")
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -507,8 +573,9 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "demo-plugin@shared-with-me".to_string(),
id: "demo-plugin@workspace-shared-with-me-private".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -527,6 +594,335 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let bundle_url = mount_remote_plugin_bundle(
&server,
"demo-plugin",
remote_plugin_bundle_tar_gz_bytes("demo-plugin")?,
)
.await;
mount_remote_plugin_detail_with_bundle(
&server,
"plugins_123",
"demo-plugin",
&bundle_url,
"WORKSPACE",
)
.await;
mount_empty_remote_installed_plugins(&server, "WORKSPACE").await;
let home_env = home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home_env.as_str())),
("USERPROFILE", Some(home_env.as_str())),
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/checkout",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareCheckoutResponse = to_response(response)?;
let plugin_path = AbsolutePathBuf::try_from(home.path().join("plugins/demo-plugin"))?;
let marketplace_path =
AbsolutePathBuf::try_from(home.path().join(".agents/plugins/marketplace.json"))?;
assert_eq!(
response,
PluginShareCheckoutResponse {
remote_plugin_id: "plugins_123".to_string(),
plugin_id: "demo-plugin@codex-curated".to_string(),
plugin_name: "demo-plugin".to_string(),
plugin_path: plugin_path.clone(),
marketplace_name: "codex-curated".to_string(),
marketplace_path: marketplace_path.clone(),
remote_version: Some("1.2.3".to_string()),
}
);
assert!(
plugin_path
.as_path()
.join(".codex-plugin/plugin.json")
.is_file()
);
let marketplace: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(marketplace_path.as_path())?)?;
assert_eq!(
marketplace,
json!({
"name": "codex-curated",
"interface": {
"displayName": "Personal",
},
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin",
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_USE",
},
},
],
})
);
let mapping: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
codex_home
.path()
.join(".tmp/plugin-share-local-paths-v1.json"),
)?)?;
assert_eq!(
mapping,
json!({
"localPluginPathsByRemotePluginId": {
"plugins_123": plugin_path.clone(),
},
})
);
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![
codex_app_server_protocol::PluginListMarketplaceKind::Local,
]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(response.marketplaces.len(), 1);
assert_eq!(response.marketplaces[0].name, "codex-curated");
assert_eq!(response.marketplaces[0].plugins[0].name, "demo-plugin");
assert_eq!(
response.marketplaces[0].plugins[0]
.share_context
.as_ref()
.map(|context| context.remote_plugin_id.as_str()),
Some("plugins_123")
);
std::fs::write(plugin_path.as_path().join("local-edit.txt"), "keep")?;
let request_id = mcp
.send_raw_request(
"plugin/share/checkout",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareCheckoutResponse = to_response(response)?;
assert_eq!(response.plugin_path, plugin_path);
assert_eq!(
std::fs::read_to_string(plugin_path.as_path().join("local-edit.txt"))?,
"keep"
);
Ok(())
}
#[tokio::test]
async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let bundle_url = format!("{}/bundles/global-plugin.tar.gz", server.uri());
mount_remote_plugin_detail_with_bundle(
&server,
"plugins_global",
"global-plugin",
&bundle_url,
"GLOBAL",
)
.await;
mount_empty_remote_installed_plugins(&server, "GLOBAL").await;
let home_env = home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home_env.as_str())),
("USERPROFILE", Some(home_env.as_str())),
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/checkout",
Some(json!({
"remotePluginId": "plugins_global",
})),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(
error
.error
.message
.contains("not available for plugin/share/checkout")
);
assert!(!home.path().join("plugins/global-plugin").exists());
Ok(())
}
#[tokio::test]
async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let marketplace_path = home.path().join(".agents/plugins/marketplace.json");
std::fs::create_dir_all(
marketplace_path
.parent()
.expect("marketplace path has parent"),
)?;
std::fs::write(
&marketplace_path,
serde_json::to_string_pretty(&json!({
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./other/demo-plugin",
},
},
],
}))?,
)?;
let bundle_url = mount_remote_plugin_bundle(
&server,
"demo-plugin",
remote_plugin_bundle_tar_gz_bytes("demo-plugin")?,
)
.await;
mount_remote_plugin_detail_with_bundle(
&server,
"plugins_123",
"demo-plugin",
&bundle_url,
"WORKSPACE",
)
.await;
mount_empty_remote_installed_plugins(&server, "WORKSPACE").await;
let home_env = home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home_env.as_str())),
("USERPROFILE", Some(home_env.as_str())),
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/checkout",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(
error
.error
.message
.contains("marketplace already contains plugin `demo-plugin`")
);
assert!(!home.path().join("plugins/demo-plugin").exists());
assert!(
!codex_home
.path()
.join(".tmp/plugin-share-local-paths-v1.json")
.exists()
);
Ok(())
}
#[tokio::test]
async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -727,8 +1123,9 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "demo-plugin@shared-with-me".to_string(),
id: "demo-plugin@workspace-shared-with-me-private".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -762,6 +1159,85 @@ remote_plugin = true
)
}
async fn mount_remote_plugin_bundle(
server: &MockServer,
plugin_name: &str,
body: Vec<u8>,
) -> String {
let bundle_path = format!("/bundles/{plugin_name}.tar.gz");
Mock::given(method("GET"))
.and(path(bundle_path.clone()))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/gzip")
.set_body_bytes(body),
)
.expect(1)
.mount(server)
.await;
format!("{}{}", server.uri(), bundle_path)
}
async fn mount_remote_plugin_detail_with_bundle(
server: &MockServer,
remote_plugin_id: &str,
plugin_name: &str,
bundle_url: &str,
scope: &str,
) {
Mock::given(method("GET"))
.and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))
.and(query_param("includeDownloadUrls", "true"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": remote_plugin_id,
"name": plugin_name,
"scope": scope,
"discoverability": "PRIVATE",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"share_principals": [
{
"principal_type": "user",
"principal_id": "user-owner__account-123",
"role": "owner",
"name": "Owner",
},
],
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.2.3",
"bundle_download_url": bundle_url,
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
"short_description": "A demo plugin",
"capabilities": ["Read", "Write"],
},
"skills": [],
},
})))
.mount(server)
.await;
}
async fn mount_empty_remote_installed_plugins(server: &MockServer, scope: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {
"next_page_token": null,
},
})))
.mount(server)
.await;
}
fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
json!({
"id": plugin_id,
@@ -786,6 +1262,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "0.1.0",
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
@@ -838,6 +1315,7 @@ fn expected_plugin_interface() -> PluginInterface {
fn expected_share_context(plugin_id: &str) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: plugin_id.to_string(),
remote_version: Some("0.1.0".to_string()),
discoverability: Some(PluginShareDiscoverability::Private),
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
creator_account_user_id: None,
@@ -872,6 +1350,32 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
Ok(plugin_path)
}
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
let skill = "# Example\n\nA test skill.\n";
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
for (path, contents, mode) in [
(
".codex-plugin/plugin.json",
manifest.as_bytes(),
/*mode*/ 0o644,
),
(
"skills/example/SKILL.md",
skill.as_bytes(),
/*mode*/ 0o644,
),
] {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(mode);
header.set_cksum();
tar.append_data(&mut header, path, contents)?;
}
Ok(tar.into_inner()?.finish()?)
}
fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {
write_file(
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
@@ -75,6 +75,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -36,11 +36,11 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> {
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-00-00")?;
let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let result = async {
let mut ws1 = connect_websocket(&socket_path).await?;
let mut ws2 = connect_websocket(&socket_path).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
initialize_both_clients(&mut ws1, &mut ws2).await?;
send_request(
@@ -91,7 +91,7 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> {
process
.kill()
.await
.context("failed to stop app-server process")?;
.context("failed to stop websocket app-server process")?;
result
}
@@ -102,11 +102,11 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> {
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-05-00")?;
let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let result = async {
let mut ws1 = connect_websocket(&socket_path).await?;
let mut ws2 = connect_websocket(&socket_path).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
initialize_both_clients(&mut ws1, &mut ws2).await?;
let renamed = "Stored rename";
@@ -143,7 +143,7 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> {
process
.kill()
.await
.context("failed to stop app-server process")?;
.context("failed to stop websocket app-server process")?;
result
}
@@ -375,6 +375,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -440,6 +441,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -525,6 +527,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -1304,7 +1307,7 @@ async fn seed_pathless_store_thread(
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("named pathless thread".to_string()),
name: Some(Some("named pathless thread".to_string())),
..Default::default()
},
include_archived: true,
@@ -226,7 +226,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("named pathless thread".to_string()),
name: Some(Some("named pathless thread".to_string())),
..Default::default()
},
include_archived: true,
@@ -245,6 +245,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+24 -3
View File
@@ -190,6 +190,7 @@ async fn run_command_under_sandbox(
.map_err(anyhow::Error::msg)?,
codex_linux_sandbox_exe,
config_options,
/*strict_config*/ false,
)
.await?;
@@ -636,12 +637,14 @@ async fn load_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
strict_config: bool,
) -> anyhow::Result<Config> {
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
options,
/*codex_home*/ None,
strict_config,
)
.await
}
@@ -651,6 +654,7 @@ async fn load_debug_sandbox_config_with_codex_home(
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
codex_home: Option<PathBuf>,
strict_config: bool,
) -> anyhow::Result<Config> {
let DebugSandboxConfigOptions {
permissions_profile,
@@ -680,6 +684,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home.clone(),
managed_requirements_mode,
strict_config,
)
.await?;
@@ -697,6 +702,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home,
managed_requirements_mode,
strict_config,
)
.await
.map_err(Into::into)
@@ -707,14 +713,16 @@ async fn build_debug_sandbox_config(
harness_overrides: ConfigOverrides,
codex_home: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
strict_config: bool,
) -> std::io::Result<Config> {
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides);
if let ManagedRequirementsMode::Ignore = managed_requirements_mode {
.harness_overrides(harness_overrides)
.strict_config(strict_config);
if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) {
builder = builder.loader_overrides(LoaderOverrides {
ignore_managed_requirements: true,
..Default::default()
..LoaderOverrides::default()
});
}
if let Some(codex_home) = codex_home {
@@ -783,6 +791,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let legacy_config = build_debug_sandbox_config(
@@ -793,6 +802,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -805,6 +815,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -840,6 +851,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let read_only_config = build_debug_sandbox_config(
@@ -850,6 +862,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -862,6 +875,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -905,6 +919,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -917,6 +932,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -942,6 +958,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -975,6 +992,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -1004,6 +1022,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -1015,6 +1034,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home.path().to_path_buf()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -1040,6 +1060,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
+387 -58
View File
@@ -55,7 +55,7 @@ use crate::marketplace_cmd::MarketplaceCli;
use crate::mcp_cmd::McpCli;
use codex_core::build_models_manager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
@@ -109,7 +109,7 @@ enum Subcommand {
Exec(ExecCli),
/// Run a code review non-interactively.
Review(ReviewArgs),
Review(ReviewCommand),
/// Manage login.
Login(LoginCommand),
@@ -124,7 +124,7 @@ enum Subcommand {
Plugin(PluginCli),
/// Start Codex as an MCP server (stdio).
McpServer,
McpServer(McpServerCommand),
/// [experimental] Run the app server or related tooling.
AppServer(AppServerCommand),
@@ -266,6 +266,23 @@ struct DebugModelsCommand {
bundled: bool,
}
#[derive(Debug, Parser)]
struct ReviewCommand {
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
#[clap(flatten)]
args: ReviewArgs,
}
#[derive(Debug, Parser)]
struct McpServerCommand {
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
}
#[derive(Debug, Parser)]
struct DebugTraceReduceCommand {
/// Trace bundle directory containing manifest.json and trace.jsonl.
@@ -419,8 +436,12 @@ struct AppServerCommand {
#[command(subcommand)]
subcommand: Option<AppServerSubcommand>,
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `unix://`, `unix://PATH`, `off`.
/// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`.
#[arg(
long = "listen",
value_name = "URL",
@@ -445,6 +466,9 @@ struct AppServerCommand {
/// See https://developers.openai.com/codex/config-advanced/#metrics for more details.
#[arg(long = "analytics-default-enabled")]
analytics_default_enabled: bool,
#[command(flatten)]
auth: codex_app_server::AppServerWebsocketAuthArgs,
}
#[derive(Debug, Parser)]
@@ -715,9 +739,9 @@ struct FeatureToggles {
#[derive(Debug, Default, Parser, Clone)]
struct InteractiveRemoteOptions {
/// Connect the TUI to a remote app server websocket endpoint.
/// Connect the TUI to a remote app server endpoint.
///
/// Accepted forms: `ws://host:port` or `wss://host:port`.
/// Accepted forms: `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`.
#[arg(long = "remote", value_name = "ADDR")]
remote: Option<String>,
@@ -803,6 +827,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_config_overrides.raw_overrides.extend(toggle_overrides);
let root_remote = remote.remote;
let root_remote_auth_token_env = remote.remote_auth_token_env;
let root_strict_config = interactive.strict_config;
reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?;
match subcommand {
None => {
@@ -828,13 +854,17 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
exec_cli
.shared
.inherit_exec_root_options(&interactive.shared);
exec_cli.strict_config |= root_strict_config;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
codex_exec::run_main(exec_cli, arg0_paths.clone()).await?;
}
Some(Subcommand::Review(review_args)) => {
Some(Subcommand::Review(ReviewCommand {
strict_config,
args: review_args,
})) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
@@ -842,19 +872,25 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
)?;
let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?;
exec_cli.command = Some(ExecCommand::Review(review_args));
exec_cli.strict_config = strict_config || root_strict_config;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
codex_exec::run_main(exec_cli, arg0_paths.clone()).await?;
}
Some(Subcommand::McpServer) => {
Some(Subcommand::McpServer(McpServerCommand { strict_config })) => {
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?;
codex_mcp_server::run_main(
arg0_paths.clone(),
root_config_overrides,
strict_config || root_strict_config,
)
.await?;
}
Some(Subcommand::Mcp(mut mcp_cli)) => {
reject_remote_mode_for_subcommand(
@@ -887,9 +923,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
subcommand,
strict_config: app_server_strict_config,
listen,
analytics_default_enabled,
auth,
} = app_server_cli;
let strict_config = app_server_strict_config || root_strict_config;
reject_strict_config_for_app_server_subcommand(strict_config, subcommand.as_ref())?;
reject_remote_mode_for_app_server_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
@@ -898,13 +938,17 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
match subcommand {
None => {
let transport = listen;
codex_app_server::run_main_with_transport(
let auth = auth.try_into_settings()?;
codex_app_server::run_main_with_transport_options(
arg0_paths.clone(),
root_config_overrides,
codex_config::LoaderOverrides::default(),
strict_config,
analytics_default_enabled,
transport,
codex_protocol::protocol::SessionSource::VSCode,
auth,
codex_app_server::AppServerRuntimeOptions::default(),
)
.await?;
}
@@ -1304,11 +1348,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
..Default::default()
};
let config = Config::load_with_cli_overrides_and_harness_overrides(
cli_kv_overrides,
overrides,
)
.await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let mut rows = Vec::with_capacity(FEATURES.len());
let mut name_width = 0;
let mut stage_width = 0;
@@ -1480,11 +1524,15 @@ async fn run_debug_prompt_input_command(
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe,
show_raw_agent_reasoning: shared.oss.then_some(true),
ephemeral: Some(true),
bypass_hook_trust: shared.bypass_hook_trust.then_some(true),
additional_writable_roots: shared.add_dir,
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let mut input = shared
.images
@@ -1515,7 +1563,10 @@ async fn run_debug_models_command(
let cli_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(cli_overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.build()
.await?;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await;
let models_manager = build_models_manager(&config, auth_manager);
@@ -1540,8 +1591,11 @@ async fn run_debug_clear_memories_command(
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let state_path = state_db_path(config.sqlite_home.as_path());
let mut cleared_state_db = false;
@@ -1576,9 +1630,7 @@ fn prepend_config_flags(
subcommand_config_overrides: &mut CliConfigOverrides,
cli_config_overrides: CliConfigOverrides,
) {
subcommand_config_overrides
.raw_overrides
.splice(0..0, cli_config_overrides.raw_overrides);
subcommand_config_overrides.prepend_root_overrides(cli_config_overrides);
}
fn reject_remote_mode_for_subcommand(
@@ -1599,12 +1651,103 @@ fn reject_remote_mode_for_subcommand(
Ok(())
}
fn reject_root_strict_config_for_subcommand(
strict_config: bool,
subcommand: &Option<Subcommand>,
) -> anyhow::Result<()> {
if !strict_config {
return Ok(());
}
match unsupported_subcommand_name_for_strict_config(subcommand) {
Some(subcommand_name) => {
reject_strict_config_for_unsupported_subcommand(strict_config, subcommand_name)
}
None => Ok(()),
}
}
/// Return the selected subcommand name when a root-level `--strict-config`
/// flag should be rejected after parsing.
///
/// `--strict-config` is parsed on the root interactive CLI so commands like
/// `codex --strict-config` continue to work for the TUI and for wrappers that
/// forward root options into another command shape. Clap will still accept that
/// root flag before the dispatcher knows which subcommand the user selected, so
/// unsupported subcommands need an explicit post-parse reject path.
///
/// `Some(...)` returns the user-facing command name fragment to embed in the
/// rejection error, such as `cloud` or `app-server proxy`. `None` means the
/// selected command is allowed to inherit root `--strict-config`.
fn unsupported_subcommand_name_for_strict_config(
subcommand: &Option<Subcommand>,
) -> Option<&'static str> {
match subcommand {
None
| Some(Subcommand::Exec(_))
| Some(Subcommand::Review(_))
| Some(Subcommand::McpServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Fork(_)) => None,
Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None,
Some(Subcommand::AppServer(app_server)) => {
Some(app_server_subcommand_name(app_server.subcommand.as_ref()))
}
Some(Subcommand::RemoteControl) => Some("remote-control"),
Some(Subcommand::Mcp(_)) => Some("mcp"),
Some(Subcommand::Plugin(_)) => Some("plugin"),
#[cfg(any(target_os = "macos", target_os = "windows"))]
Some(Subcommand::App(_)) => Some("app"),
Some(Subcommand::Login(_)) => Some("login"),
Some(Subcommand::Logout(_)) => Some("logout"),
Some(Subcommand::Completion(_)) => Some("completion"),
Some(Subcommand::Update) => Some("update"),
Some(Subcommand::Cloud(_)) => Some("cloud"),
Some(Subcommand::Sandbox(_)) => Some("sandbox"),
Some(Subcommand::Debug(_)) => Some("debug"),
Some(Subcommand::Execpolicy(_)) => Some("execpolicy"),
Some(Subcommand::Apply(_)) => Some("apply"),
Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"),
Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"),
Some(Subcommand::ExecServer(_)) => Some("exec-server"),
Some(Subcommand::Features(_)) => Some("features"),
}
}
fn reject_strict_config_for_app_server_subcommand(
strict_config: bool,
subcommand: Option<&AppServerSubcommand>,
) -> anyhow::Result<()> {
if subcommand.is_none() {
return Ok(());
}
reject_strict_config_for_unsupported_subcommand(
strict_config,
app_server_subcommand_name(subcommand),
)
}
fn reject_strict_config_for_unsupported_subcommand(
strict_config: bool,
subcommand: &str,
) -> anyhow::Result<()> {
if strict_config {
anyhow::bail!("`--strict-config` is not supported for `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 {
let subcommand_name = app_server_subcommand_name(subcommand);
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
}
fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str {
match subcommand {
None => "app-server",
Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand {
AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap",
@@ -1626,8 +1769,7 @@ fn reject_remote_mode_for_app_server_subcommand(
Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => {
"app-server generate-internal-json-schema"
}
};
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
}
}
async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> {
@@ -1693,27 +1835,39 @@ async fn run_interactive_tui(
}
}
let normalized_remote = remote
let mut remote_endpoint = remote
.as_deref()
.map(codex_tui::normalize_remote_addr)
.map(codex_tui::resolve_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 let Some(remote_auth_token_env) = remote_auth_token_env {
let Some(endpoint) = remote_endpoint.as_mut() else {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires `--remote`.",
));
};
if !codex_tui::remote_addr_supports_auth_token(endpoint) {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
}
let auth_token = read_remote_auth_token_from_env_var(&remote_auth_token_env)
.map_err(std::io::Error::other)?;
let codex_tui::RemoteAppServerEndpoint::WebSocket {
auth_token: slot, ..
} = endpoint
else {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
};
*slot = Some(auth_token);
}
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::run_main(
interactive,
arg0_paths,
codex_config::LoaderOverrides::default(),
normalized_remote,
remote_auth_token,
remote_endpoint,
)
.await
}
@@ -1787,6 +1941,7 @@ fn finalize_fork_interactive(
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
let TuiCli {
shared,
strict_config,
approval_policy,
web_search,
prompt,
@@ -1802,6 +1957,9 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
if web_search {
interactive.web_search = true;
}
if strict_config {
interactive.strict_config = true;
}
if let Some(prompt) = prompt {
// Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state.
interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
@@ -2289,6 +2447,7 @@ mod tests {
"my-profile",
"-C",
"/tmp",
"--strict-config",
"-i",
"/tmp/a.png,/tmp/b.png",
]
@@ -2311,6 +2470,7 @@ mod tests {
Some(std::path::Path::new("/tmp"))
);
assert!(interactive.web_search);
assert!(interactive.strict_config);
let has_a = interactive
.images
.iter()
@@ -2341,6 +2501,18 @@ mod tests {
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_merges_bypass_hook_trust_flag() {
let interactive = finalize_resume_from_args(
["codex", "resume", "--dangerously-bypass-hook-trust"].as_ref(),
);
assert!(interactive.bypass_hook_trust);
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn fork_picker_logic_none_and_not_last() {
let interactive = finalize_fork_from_args(["codex", "fork"].as_ref());
@@ -2392,15 +2564,81 @@ mod tests {
assert!(app_server.analytics_default_enabled);
}
#[test]
fn strict_config_parses_for_supported_commands() {
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config"]).expect("parse");
assert!(cli.interactive.strict_config);
let cli = MultitoolCli::try_parse_from(["codex", "mcp-server", "--strict-config"])
.expect("parse");
assert_matches!(
cli.subcommand,
Some(Subcommand::McpServer(McpServerCommand {
strict_config: true,
}))
);
let cli =
MultitoolCli::try_parse_from(["codex", "review", "--strict-config", "--uncommitted"])
.expect("parse");
assert_matches!(
cli.subcommand,
Some(Subcommand::Review(ReviewCommand {
strict_config: true,
..
}))
);
}
#[test]
fn root_strict_config_is_rejected_for_unsupported_subcommands() {
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "mcp", "list"])
.expect("parse");
let err = reject_root_strict_config_for_subcommand(
cli.interactive.strict_config,
&cli.subcommand,
)
.expect_err("mcp should not support root --strict-config");
assert_eq!(
err.to_string(),
"`--strict-config` is not supported for `codex mcp`"
);
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "remote-control"])
.expect("parse");
let err = reject_root_strict_config_for_subcommand(
cli.interactive.strict_config,
&cli.subcommand,
)
.expect_err("remote-control should not support root --strict-config");
assert_eq!(
err.to_string(),
"`--strict-config` is not supported for `codex remote-control`"
);
}
#[test]
fn app_server_subcommands_reject_strict_config() {
let app_server =
app_server_from_args(["codex", "app-server", "--strict-config", "proxy"].as_ref());
let err = reject_strict_config_for_app_server_subcommand(
app_server.strict_config,
app_server.subcommand.as_ref(),
)
.expect_err("app-server proxy should not support --strict-config");
assert_eq!(
err.to_string(),
"`--strict-config` is not supported for `codex app-server proxy`"
);
}
#[test]
fn reject_remote_flag_for_remote_control() {
let cli = MultitoolCli::try_parse_from([
"codex",
"--remote",
"ws://127.0.0.1:1234",
"remote-control",
])
.expect("parse");
let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"])
.expect("parse");
assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl));
let err = reject_remote_mode_for_subcommand(
@@ -2415,9 +2653,9 @@ mod tests {
#[test]
fn remote_flag_parses_for_interactive_root() {
let cli = MultitoolCli::try_parse_from(["codex", "--remote", "ws://127.0.0.1:4500"])
let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://codex.sock"])
.expect("parse");
assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500"));
assert_eq!(cli.remote.remote.as_deref(), Some("unix://codex.sock"));
}
#[test]
@@ -2439,14 +2677,14 @@ mod tests {
#[test]
fn remote_flag_parses_for_resume_subcommand() {
let cli =
MultitoolCli::try_parse_from(["codex", "resume", "--remote", "ws://127.0.0.1:4500"])
MultitoolCli::try_parse_from(["codex", "resume", "--remote", "unix://codex.sock"])
.expect("parse");
let Subcommand::Resume(ResumeCommand { remote, .. }) =
cli.subcommand.expect("resume present")
else {
panic!("expected resume subcommand");
};
assert_eq!(remote.remote.as_deref(), Some("ws://127.0.0.1:4500"));
assert_eq!(remote.remote.as_deref(), Some("unix://codex.sock"));
}
#[test]
@@ -2521,14 +2759,16 @@ mod tests {
}
#[test]
fn app_server_listen_websocket_url_fails_to_parse() {
let parse_result = MultitoolCli::try_parse_from([
"codex",
"app-server",
"--listen",
"ws://127.0.0.1:4500",
]);
assert!(parse_result.is_err());
fn app_server_listen_websocket_url_parses() {
let app_server = app_server_from_args(
["codex", "app-server", "--listen", "ws://127.0.0.1:4500"].as_ref(),
);
assert_eq!(
app_server.listen,
codex_app_server::AppServerTransport::WebSocket {
bind_address: "127.0.0.1:4500".parse().expect("valid socket address"),
}
);
}
#[test]
@@ -2697,6 +2937,61 @@ mod tests {
assert!(err.to_string().contains("app-server daemon version"));
}
#[test]
fn app_server_capability_token_flags_parse() {
let app_server = app_server_from_args(
[
"codex",
"app-server",
"--ws-auth",
"capability-token",
"--ws-token-file",
"/tmp/codex-token",
]
.as_ref(),
);
assert_eq!(
app_server.auth.ws_auth,
Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken)
);
assert_eq!(
app_server.auth.ws_token_file,
Some(PathBuf::from("/tmp/codex-token"))
);
}
#[test]
fn app_server_signed_bearer_flags_parse() {
let app_server = app_server_from_args(
[
"codex",
"app-server",
"--ws-auth",
"signed-bearer-token",
"--ws-shared-secret-file",
"/tmp/codex-secret",
"--ws-issuer",
"issuer",
"--ws-audience",
"audience",
"--ws-max-clock-skew-seconds",
"9",
]
.as_ref(),
);
assert_eq!(
app_server.auth.ws_auth,
Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken)
);
assert_eq!(
app_server.auth.ws_shared_secret_file,
Some(PathBuf::from("/tmp/codex-secret"))
);
assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer"));
assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience"));
assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9));
}
#[test]
fn app_server_rejects_removed_insecure_non_loopback_flag() {
let parse_result = MultitoolCli::try_parse_from([
@@ -2786,4 +3081,38 @@ mod tests {
.expect_err("feature should be rejected");
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_unknown_enable_errors() {
let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref());
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_unknown_disable_errors() {
let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref());
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_compound_enable_errors() {
let err = strict_config_feature_toggle_error(
["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(),
);
assert_eq!(
err.to_string(),
"Unknown feature flag: multi_agent_v2.subagent_usage_hint_text"
);
}
fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error {
let cli_args = std::iter::once("codex")
.chain(std::iter::once("--strict-config"))
.chain(args.iter().copied());
let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed");
assert!(cli.interactive.strict_config);
cli.feature_toggles
.to_overrides()
.expect_err("feature should be rejected")
}
}
+30
View File
@@ -0,0 +1,30 @@
use std::path::Path;
use anyhow::Result;
use predicates::str::contains;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[test]
fn strict_config_rejects_unknown_config_fields_for_app_server() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
foo = "bar"
"#,
)?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["app-server", "--strict-config", "--listen", "off"])
.assert()
.failure()
.stderr(contains("unknown configuration field"));
Ok(())
}
+28
View File
@@ -11,6 +11,34 @@ fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
Ok(cmd)
}
#[test]
fn strict_config_rejects_unknown_config_override() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--strict-config", "-c", "foo=bar", "mcp-server"])
.assert()
.failure()
.stderr(contains("unknown configuration field"));
Ok(())
}
#[test]
fn strict_config_is_not_supported_for_cloud_command() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--strict-config", "-c", "foo=bar", "cloud", "list"])
.assert()
.failure()
.stderr(contains(
"`--strict-config` is not supported for `codex cloud`",
));
Ok(())
}
#[tokio::test]
async fn features_enable_writes_feature_flag_to_config() -> Result<()> {
let codex_home = TempDir::new()?;
+16
View File
@@ -1204,6 +1204,7 @@ mod tests {
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1286,6 +1287,7 @@ mod tests {
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1319,6 +1321,7 @@ mod tests {
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1369,6 +1372,7 @@ mod tests {
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1519,6 +1523,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1599,6 +1604,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1677,6 +1683,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1883,6 +1890,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1923,6 +1931,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -1983,6 +1992,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -2039,6 +2049,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -2097,6 +2108,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -2156,6 +2168,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -2215,6 +2228,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -2304,6 +2318,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
@@ -2335,6 +2350,7 @@ command = "sample-mcp"
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
+3
View File
@@ -23,9 +23,12 @@ pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS;
pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL;
pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS;
pub use runtime::ExecuteRequest;
pub use runtime::ExecuteToPendingOutcome;
pub use runtime::RuntimeResponse;
pub use runtime::WaitOutcome;
pub use runtime::WaitRequest;
pub use runtime::WaitToPendingOutcome;
pub use runtime::WaitToPendingRequest;
pub use service::CodeModeService;
pub use service::CodeModeTurnHost;
pub use service::CodeModeTurnWorker;
+4 -7
View File
@@ -42,20 +42,16 @@ pub(super) fn tool_callback(
let promise = resolver.get_promise(scope);
let resolver = v8::Global::new(scope, resolver);
let tool_name = {
let (tool_name, tool_kind) = {
let Some(state) = scope.get_slot::<RuntimeState>() else {
throw_type_error(scope, "runtime state unavailable");
return;
};
let Some(tool_name) = state
.enabled_tools
.get(tool_index)
.map(|tool| tool.tool_name.clone())
else {
let Some(tool) = state.enabled_tools.get(tool_index) else {
throw_type_error(scope, "tool callback data is out of range");
return;
};
tool_name
(tool.tool_name.clone(), tool.kind)
};
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
@@ -69,6 +65,7 @@ pub(super) fn tool_callback(
let _ = event_tx.send(RuntimeEvent::ToolCall {
id,
name: tool_name,
kind: tool_kind,
input,
});
retval.set(promise.into());
+167 -5
View File
@@ -14,6 +14,7 @@ use serde::Serialize;
use serde_json::Value as JsonValue;
use tokio::sync::mpsc;
use crate::description::CodeModeToolKind;
use crate::description::EnabledToolMetadata;
use crate::description::ToolDefinition;
use crate::description::enabled_tool_metadata;
@@ -46,6 +47,11 @@ pub struct WaitRequest {
pub terminate: bool,
}
#[derive(Clone, Debug)]
pub struct WaitToPendingRequest {
pub cell_id: String,
}
/// Result of waiting on a code-mode cell.
///
/// The wrapped `RuntimeResponse` is the model-facing wait result. The enum
@@ -61,6 +67,34 @@ pub enum WaitOutcome {
MissingCell(RuntimeResponse),
}
/// Result of executing a code-mode cell until it either completes or reaches a
/// quiescent pending state.
#[derive(Debug, PartialEq)]
pub enum ExecuteToPendingOutcome {
/// The cell is waiting for more runtime input after draining the runtime
/// input queue that was ready at the pending boundary.
Pending {
cell_id: String,
content_items: Vec<FunctionCallOutputContentItem>,
/// Runtime tool-call ids emitted before this paused execution frontier
/// sealed. Hosts can use these ids to drain their tool-call transport
/// before surfacing the pending boundary to callers.
pending_tool_call_ids: Vec<String>,
},
/// The cell reached a terminal runtime response before going pending.
Completed(RuntimeResponse),
}
/// Result of resuming a live code-mode cell until it completes or becomes
/// quiescent again.
#[derive(Debug, PartialEq)]
pub enum WaitToPendingOutcome {
/// The requested code cell was live when the wait command was accepted.
LiveCell(ExecuteToPendingOutcome),
/// The requested code cell was not live.
MissingCell(RuntimeResponse),
}
impl From<WaitOutcome> for RuntimeResponse {
fn from(outcome: WaitOutcome) -> Self {
match outcome {
@@ -97,6 +131,7 @@ pub struct CodeModeNestedToolCall {
pub cell_id: String,
pub runtime_tool_call_id: String,
pub tool_name: ToolName,
pub tool_kind: CodeModeToolKind,
pub input: Option<JsonValue>,
}
@@ -118,14 +153,28 @@ pub(crate) enum RuntimeCommand {
Terminate,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PendingRuntimeMode {
Continue,
PauseUntilResumed,
}
#[derive(Debug)]
pub(crate) enum RuntimeControlCommand {
Resume,
Terminate,
}
#[derive(Debug)]
pub(crate) enum RuntimeEvent {
Started,
Pending,
ContentItem(FunctionCallOutputContentItem),
YieldRequested,
ToolCall {
id: String,
name: ToolName,
kind: CodeModeToolKind,
input: Option<JsonValue>,
},
Notify {
@@ -141,10 +190,19 @@ pub(crate) enum RuntimeEvent {
pub(crate) fn spawn_runtime(
request: ExecuteRequest,
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
) -> Result<(std_mpsc::Sender<RuntimeCommand>, v8::IsolateHandle), String> {
pending_mode: PendingRuntimeMode,
) -> Result<
(
std_mpsc::Sender<RuntimeCommand>,
std_mpsc::Sender<RuntimeControlCommand>,
v8::IsolateHandle,
),
String,
> {
initialize_v8()?;
let (command_tx, command_rx) = std_mpsc::channel();
let (control_tx, control_rx) = std_mpsc::channel();
let runtime_command_tx = command_tx.clone();
let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1);
let enabled_tools = request
@@ -164,6 +222,8 @@ pub(crate) fn spawn_runtime(
config,
event_tx,
command_rx,
control_rx,
pending_mode,
isolate_handle_tx,
runtime_command_tx,
);
@@ -172,7 +232,7 @@ pub(crate) fn spawn_runtime(
let isolate_handle = isolate_handle_rx
.recv()
.map_err(|_| "failed to initialize code mode runtime".to_string())?;
Ok((command_tx, isolate_handle))
Ok((command_tx, control_tx, isolate_handle))
}
#[derive(Clone)]
@@ -224,6 +284,8 @@ fn run_runtime(
config: RuntimeConfig,
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
command_rx: std_mpsc::Receiver<RuntimeCommand>,
control_rx: std_mpsc::Receiver<RuntimeControlCommand>,
pending_mode: PendingRuntimeMode,
isolate_handle_tx: std_mpsc::SyncSender<v8::IsolateHandle>,
runtime_command_tx: std_mpsc::Sender<RuntimeCommand>,
) {
@@ -279,7 +341,8 @@ fn run_runtime(
let mut pending_promise = pending_promise;
loop {
let Ok(command) = command_rx.recv() else {
let Some(command) = next_runtime_command(&event_tx, &command_rx, &control_rx, pending_mode)
else {
break;
};
@@ -330,6 +393,30 @@ fn run_runtime(
}
}
fn next_runtime_command(
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
command_rx: &std_mpsc::Receiver<RuntimeCommand>,
control_rx: &std_mpsc::Receiver<RuntimeControlCommand>,
pending_mode: PendingRuntimeMode,
) -> Option<RuntimeCommand> {
loop {
match command_rx.try_recv() {
Ok(command) => return Some(command),
Err(std_mpsc::TryRecvError::Disconnected) => return None,
Err(std_mpsc::TryRecvError::Empty) => {}
}
let _ = event_tx.send(RuntimeEvent::Pending);
match pending_mode {
PendingRuntimeMode::Continue => return command_rx.recv().ok(),
PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? {
RuntimeControlCommand::Resume => continue,
RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate),
},
}
}
}
fn capture_scope_send_error(
scope: &mut v8::PinScope<'_, '_>,
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
@@ -363,8 +450,12 @@ mod tests {
use tokio::sync::mpsc;
use super::ExecuteRequest;
use super::PendingRuntimeMode;
use super::RuntimeCommand;
use super::RuntimeControlCommand;
use super::RuntimeEvent;
use super::spawn_runtime;
use crate::FunctionCallOutputContentItem;
fn execute_request(source: &str) -> ExecuteRequest {
ExecuteRequest {
@@ -381,8 +472,12 @@ mod tests {
#[tokio::test]
async fn terminate_execution_stops_cpu_bound_module() {
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let (_runtime_tx, runtime_terminate_handle) =
spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap();
let (_runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime(
execute_request("while (true) {}"),
event_tx,
PendingRuntimeMode::Continue,
)
.unwrap();
let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
@@ -413,4 +508,71 @@ mod tests {
.is_none()
);
}
#[tokio::test]
async fn pending_mode_freezes_runtime_commands_until_resume() {
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let (runtime_tx, runtime_control_tx, _runtime_terminate_handle) = spawn_runtime(
execute_request(
r#"
await new Promise((resolve) => setTimeout(resolve, 60_000));
text("after");
await new Promise(() => {});
"#,
),
event_tx,
PendingRuntimeMode::PauseUntilResumed,
)
.unwrap();
assert!(matches!(
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
.unwrap()
.unwrap(),
RuntimeEvent::Started
));
assert!(matches!(
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
.unwrap()
.unwrap(),
RuntimeEvent::Pending
));
runtime_tx
.send(RuntimeCommand::TimeoutFired { id: 1 })
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(100), event_rx.recv())
.await
.is_err()
);
runtime_control_tx
.send(RuntimeControlCommand::Resume)
.unwrap();
let content_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
.unwrap()
.unwrap();
let RuntimeEvent::ContentItem(FunctionCallOutputContentItem::InputText { text }) =
content_event
else {
panic!("expected resumed runtime output");
};
assert_eq!(text, "after");
assert!(matches!(
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
.unwrap()
.unwrap(),
RuntimeEvent::Pending
));
runtime_control_tx
.send(RuntimeControlCommand::Terminate)
.unwrap();
}
}
+591 -38
View File
@@ -16,12 +16,17 @@ use crate::FunctionCallOutputContentItem;
use crate::runtime::CodeModeNestedToolCall;
use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS;
use crate::runtime::ExecuteRequest;
use crate::runtime::ExecuteToPendingOutcome;
use crate::runtime::PendingRuntimeMode;
use crate::runtime::RuntimeCommand;
use crate::runtime::RuntimeControlCommand;
use crate::runtime::RuntimeEvent;
use crate::runtime::RuntimeResponse;
use crate::runtime::TurnMessage;
use crate::runtime::WaitOutcome;
use crate::runtime::WaitRequest;
use crate::runtime::WaitToPendingOutcome;
use crate::runtime::WaitToPendingRequest;
use crate::runtime::spawn_runtime;
#[async_trait]
@@ -90,18 +95,57 @@ impl CodeModeService {
}
pub async fn execute(&self, request: ExecuteRequest) -> Result<RuntimeResponse, String> {
let cell_id = request.cell_id.clone();
let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS);
let (response_tx, response_rx) = oneshot::channel();
self.start_session(
request,
SessionResponseSender::Runtime(response_tx),
Some(initial_yield_time_ms),
PendingRuntimeMode::Continue,
)
.await?;
response_rx
.await
.map_err(|_| "exec runtime ended unexpectedly".to_string())
}
pub async fn execute_to_pending(
&self,
request: ExecuteRequest,
) -> Result<ExecuteToPendingOutcome, String> {
let (response_tx, response_rx) = oneshot::channel();
self.start_session(
request,
SessionResponseSender::ExecuteToPending(response_tx),
/*initial_yield_time_ms*/ None,
PendingRuntimeMode::PauseUntilResumed,
)
.await?;
response_rx
.await
.map_err(|_| "exec runtime ended unexpectedly".to_string())
}
async fn start_session(
&self,
request: ExecuteRequest,
initial_response_tx: SessionResponseSender,
initial_yield_time_ms: Option<u64>,
pending_mode: PendingRuntimeMode,
) -> Result<(), String> {
let cell_id = request.cell_id.clone();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (control_tx, control_rx) = mpsc::unbounded_channel();
let (response_tx, response_rx) = oneshot::channel();
let (runtime_tx, runtime_terminate_handle) = {
let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = {
let mut sessions = self.inner.sessions.lock().await;
if sessions.contains_key(&cell_id) {
return Err(format!("exec cell {cell_id} already exists"));
}
let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request, event_tx)?;
let (runtime_tx, runtime_control_tx, runtime_terminate_handle) =
spawn_runtime(request, event_tx, pending_mode)?;
// Keep the session registry locked through insertion so a
// caller-owned cell id cannot race with another execute and replace
@@ -113,7 +157,7 @@ impl CodeModeService {
runtime_tx: runtime_tx.clone(),
},
);
(runtime_tx, runtime_terminate_handle)
(runtime_tx, runtime_control_tx, runtime_terminate_handle)
};
tokio::spawn(run_session_control(
@@ -121,17 +165,17 @@ impl CodeModeService {
SessionControlContext {
cell_id: cell_id.clone(),
runtime_tx,
runtime_control_tx,
pending_mode,
runtime_terminate_handle,
},
event_rx,
control_rx,
response_tx,
initial_response_tx,
initial_yield_time_ms,
));
response_rx
.await
.map_err(|_| "exec runtime ended unexpectedly".to_string())
Ok(())
}
pub async fn wait(&self, request: WaitRequest) -> Result<WaitOutcome, String> {
@@ -166,6 +210,41 @@ impl CodeModeService {
}
}
pub async fn wait_to_pending(
&self,
request: WaitToPendingRequest,
) -> Result<WaitToPendingOutcome, String> {
let cell_id = request.cell_id.clone();
let handle = self
.inner
.sessions
.lock()
.await
.get(&request.cell_id)
.cloned();
let Some(handle) = handle else {
return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response(
cell_id,
)));
};
let (response_tx, response_rx) = oneshot::channel();
if handle
.control_tx
.send(SessionControlCommand::PollToPending { response_tx })
.is_err()
{
return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response(
cell_id,
)));
}
match response_rx.await {
Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)),
Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response(
request.cell_id,
))),
}
}
pub fn start_turn_worker(&self, host: Arc<dyn CodeModeTurnHost>) -> CodeModeTurnWorker {
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
let inner = Arc::clone(&self.inner);
@@ -255,11 +334,19 @@ enum SessionControlCommand {
yield_time_ms: u64,
response_tx: oneshot::Sender<RuntimeResponse>,
},
PollToPending {
response_tx: oneshot::Sender<ExecuteToPendingOutcome>,
},
Terminate {
response_tx: oneshot::Sender<RuntimeResponse>,
},
}
enum SessionResponseSender {
Runtime(oneshot::Sender<RuntimeResponse>),
ExecuteToPending(oneshot::Sender<ExecuteToPendingOutcome>),
}
struct PendingResult {
content_items: Vec<FunctionCallOutputContentItem>,
stored_values: HashMap<String, JsonValue>,
@@ -269,6 +356,8 @@ struct PendingResult {
struct SessionControlContext {
cell_id: String,
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
runtime_control_tx: std::sync::mpsc::Sender<RuntimeControlCommand>,
pending_mode: PendingRuntimeMode,
runtime_terminate_handle: v8::IsolateHandle,
}
@@ -290,14 +379,26 @@ fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeRespo
}
}
fn send_terminal_response(response_tx: SessionResponseSender, response: RuntimeResponse) {
match response_tx {
SessionResponseSender::Runtime(response_tx) => {
let _ = response_tx.send(response);
}
SessionResponseSender::ExecuteToPending(response_tx) => {
let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response));
}
}
}
fn send_or_buffer_result(
cell_id: &str,
result: PendingResult,
response_tx: &mut Option<oneshot::Sender<RuntimeResponse>>,
response_tx: &mut Option<SessionResponseSender>,
pending_result: &mut Option<PendingResult>,
) -> bool {
if let Some(response_tx) = response_tx.take() {
let _ = response_tx.send(pending_result_response(cell_id, result));
let response = pending_result_response(cell_id, result);
send_terminal_response(response_tx, response);
return true;
}
@@ -305,20 +406,46 @@ fn send_or_buffer_result(
false
}
fn send_yield_response(
cell_id: &str,
content_items: &mut Vec<FunctionCallOutputContentItem>,
response_tx: &mut Option<SessionResponseSender>,
) {
let Some(current_response_tx) = response_tx.take() else {
return;
};
match current_response_tx {
SessionResponseSender::Runtime(response_tx) => {
let _ = response_tx.send(RuntimeResponse::Yielded {
cell_id: cell_id.to_string(),
content_items: std::mem::take(content_items),
});
}
SessionResponseSender::ExecuteToPending(execute_to_pending_tx) => {
*response_tx = Some(SessionResponseSender::ExecuteToPending(
execute_to_pending_tx,
));
}
}
}
async fn run_session_control(
inner: Arc<Inner>,
context: SessionControlContext,
mut event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
mut control_rx: mpsc::UnboundedReceiver<SessionControlCommand>,
initial_response_tx: oneshot::Sender<RuntimeResponse>,
initial_yield_time_ms: u64,
initial_response_tx: SessionResponseSender,
initial_yield_time_ms: Option<u64>,
) {
let SessionControlContext {
cell_id,
runtime_tx,
runtime_control_tx,
pending_mode,
runtime_terminate_handle,
} = context;
let mut content_items = Vec::new();
let mut pending_tool_call_ids = Vec::new();
let mut pending_result: Option<PendingResult> = None;
let mut response_tx = Some(initial_response_tx);
let mut termination_requested = false;
@@ -338,10 +465,11 @@ async fn run_session_control(
runtime_closed = true;
if termination_requested {
if let Some(response_tx) = response_tx.take() {
let _ = response_tx.send(RuntimeResponse::Terminated {
let response = RuntimeResponse::Terminated {
cell_id: cell_id.clone(),
content_items: std::mem::take(&mut content_items),
});
};
send_terminal_response(response_tx, response);
}
break;
}
@@ -364,19 +492,35 @@ async fn run_session_control(
};
match event {
RuntimeEvent::Started => {
yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms))));
yield_timer = initial_yield_time_ms.map(|initial_yield_time_ms| {
Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))
});
}
RuntimeEvent::Pending => {
if let Some(current_response_tx) = response_tx.take() {
match current_response_tx {
SessionResponseSender::Runtime(runtime_response_tx) => {
response_tx =
Some(SessionResponseSender::Runtime(runtime_response_tx));
}
SessionResponseSender::ExecuteToPending(response_tx) => {
let _ = response_tx.send(ExecuteToPendingOutcome::Pending {
cell_id: cell_id.clone(),
content_items: std::mem::take(&mut content_items),
pending_tool_call_ids: std::mem::take(
&mut pending_tool_call_ids,
),
});
}
}
}
}
RuntimeEvent::ContentItem(item) => {
content_items.push(item);
}
RuntimeEvent::YieldRequested => {
yield_timer = None;
if let Some(response_tx) = response_tx.take() {
let _ = response_tx.send(RuntimeResponse::Yielded {
cell_id: cell_id.clone(),
content_items: std::mem::take(&mut content_items),
});
}
send_yield_response(&cell_id, &mut content_items, &mut response_tx);
}
RuntimeEvent::Notify { call_id, text } => {
let _ = inner.turn_message_tx.send(TurnMessage::Notify {
@@ -385,11 +529,20 @@ async fn run_session_control(
text,
}).await;
}
RuntimeEvent::ToolCall { id, name, input } => {
RuntimeEvent::ToolCall {
id,
name,
kind,
input,
} => {
if pending_mode == PendingRuntimeMode::PauseUntilResumed {
pending_tool_call_ids.push(id.clone());
}
let tool_call = CodeModeNestedToolCall {
cell_id: cell_id.clone(),
runtime_tool_call_id: id,
tool_name: name,
tool_kind: kind,
input,
};
let _ = inner
@@ -404,10 +557,11 @@ async fn run_session_control(
yield_timer = None;
if termination_requested {
if let Some(response_tx) = response_tx.take() {
let _ = response_tx.send(RuntimeResponse::Terminated {
let response = RuntimeResponse::Terminated {
cell_id: cell_id.clone(),
content_items: std::mem::take(&mut content_items),
});
};
send_terminal_response(response_tx, response);
}
break;
}
@@ -440,8 +594,23 @@ async fn run_session_control(
let _ = next_response_tx.send(pending_result_response(&cell_id, result));
break;
}
response_tx = Some(next_response_tx);
response_tx = Some(SessionResponseSender::Runtime(next_response_tx));
yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms))));
resume_paused_runtime(&runtime_control_tx, pending_mode);
}
SessionControlCommand::PollToPending {
response_tx: next_response_tx,
} => {
if let Some(result) = pending_result.take() {
let response = pending_result_response(&cell_id, result);
let _ = next_response_tx
.send(ExecuteToPendingOutcome::Completed(response));
break;
}
response_tx =
Some(SessionResponseSender::ExecuteToPending(next_response_tx));
yield_timer = None;
resume_paused_runtime(&runtime_control_tx, pending_mode);
}
SessionControlCommand::Terminate { response_tx: next_response_tx } => {
if let Some(result) = pending_result.take() {
@@ -449,17 +618,19 @@ async fn run_session_control(
break;
}
response_tx = Some(next_response_tx);
response_tx = Some(SessionResponseSender::Runtime(next_response_tx));
termination_requested = true;
yield_timer = None;
let _ = runtime_tx.send(RuntimeCommand::Terminate);
terminate_paused_runtime(&runtime_control_tx, pending_mode);
let _ = runtime_terminate_handle.terminate_execution();
if runtime_closed {
if let Some(response_tx) = response_tx.take() {
let _ = response_tx.send(RuntimeResponse::Terminated {
let response = RuntimeResponse::Terminated {
cell_id: cell_id.clone(),
content_items: std::mem::take(&mut content_items),
});
};
send_terminal_response(response_tx, response);
}
break;
} else {
@@ -476,20 +647,34 @@ async fn run_session_control(
}
} => {
yield_timer = None;
if let Some(response_tx) = response_tx.take() {
let _ = response_tx.send(RuntimeResponse::Yielded {
cell_id: cell_id.clone(),
content_items: std::mem::take(&mut content_items),
});
}
send_yield_response(&cell_id, &mut content_items, &mut response_tx);
}
}
}
let _ = runtime_tx.send(RuntimeCommand::Terminate);
terminate_paused_runtime(&runtime_control_tx, pending_mode);
inner.sessions.lock().await.remove(&cell_id);
}
fn resume_paused_runtime(
runtime_control_tx: &std::sync::mpsc::Sender<RuntimeControlCommand>,
pending_mode: PendingRuntimeMode,
) {
if pending_mode == PendingRuntimeMode::PauseUntilResumed {
let _ = runtime_control_tx.send(RuntimeControlCommand::Resume);
}
}
fn terminate_paused_runtime(
runtime_control_tx: &std::sync::mpsc::Sender<RuntimeControlCommand>,
pending_mode: PendingRuntimeMode,
) {
if pending_mode == PendingRuntimeMode::PauseUntilResumed {
let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate);
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@@ -497,6 +682,7 @@ mod tests {
use std::sync::atomic::AtomicU64;
use std::time::Duration;
use codex_protocol::ToolName;
use pretty_assertions::assert_eq;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
@@ -504,15 +690,22 @@ mod tests {
use super::CodeModeService;
use super::Inner;
use super::PendingRuntimeMode;
use super::RuntimeCommand;
use super::RuntimeResponse;
use super::SessionControlCommand;
use super::SessionControlContext;
use super::SessionResponseSender;
use super::WaitOutcome;
use super::WaitRequest;
use super::WaitToPendingOutcome;
use super::WaitToPendingRequest;
use super::run_session_control;
use crate::CodeModeToolKind;
use crate::FunctionCallOutputContentItem;
use crate::ToolDefinition;
use crate::runtime::ExecuteRequest;
use crate::runtime::ExecuteToPendingOutcome;
use crate::runtime::RuntimeEvent;
use crate::runtime::spawn_runtime;
@@ -565,6 +758,363 @@ mod tests {
);
}
#[tokio::test]
async fn execute_to_pending_returns_completed_for_synchronous_results() {
let service = CodeModeService::new();
let response = service
.execute_to_pending(ExecuteRequest {
source: r#"text("done");"#.to_string(),
yield_time_ms: Some(60_000),
..execute_request("")
})
.await
.unwrap();
assert_eq!(
response,
ExecuteToPendingOutcome::Completed(RuntimeResponse::Result {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "done".to_string(),
}],
stored_values: HashMap::new(),
error_text: None,
})
);
}
#[tokio::test]
async fn execute_to_pending_returns_once_the_runtime_is_quiescent() {
let service = CodeModeService::new();
let response = tokio::time::timeout(
Duration::from_secs(1),
service.execute_to_pending(ExecuteRequest {
source: r#"text("before"); await new Promise(() => {});"#.to_string(),
yield_time_ms: Some(60_000),
..execute_request("")
}),
)
.await
.unwrap()
.unwrap();
assert_eq!(
response,
ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
}],
pending_tool_call_ids: Vec::new(),
}
);
let termination = service
.wait(WaitRequest {
cell_id: "1".to_string(),
yield_time_ms: 1,
terminate: true,
})
.await
.unwrap();
assert_eq!(
termination,
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
cell_id: "1".to_string(),
content_items: Vec::new(),
})
);
}
#[tokio::test]
async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() {
let service = CodeModeService::new();
let response = service
.execute_to_pending(ExecuteRequest {
enabled_tools: vec![ToolDefinition {
name: "echo".to_string(),
tool_name: ToolName::plain("echo"),
description: String::new(),
kind: CodeModeToolKind::Function,
input_schema: None,
output_schema: None,
}],
source: r#"
await Promise.all([
tools.echo({ value: "first" }),
tools.echo({ value: "second" }),
]);
"#
.to_string(),
yield_time_ms: Some(60_000),
..execute_request("")
})
.await
.unwrap();
assert_eq!(
response,
ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: Vec::new(),
pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()],
}
);
let termination = service
.wait(WaitRequest {
cell_id: "1".to_string(),
yield_time_ms: 1,
terminate: true,
})
.await
.unwrap();
assert_eq!(
termination,
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
cell_id: "1".to_string(),
content_items: Vec::new(),
})
);
}
#[tokio::test]
async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() {
let service = CodeModeService::new();
let initial_response = service
.execute_to_pending(ExecuteRequest {
enabled_tools: vec![ToolDefinition {
name: "echo".to_string(),
tool_name: ToolName::plain("echo"),
description: String::new(),
kind: CodeModeToolKind::Function,
input_schema: None,
output_schema: None,
}],
source: r#"
setTimeout(() => {
tools.echo({ value: "delayed" });
}, 1000);
await Promise.all([
tools.echo({ value: "second" }),
tools.echo({ value: "third" }),
]);
"#
.to_string(),
yield_time_ms: Some(60_000),
..execute_request("")
})
.await
.unwrap();
assert_eq!(
initial_response,
ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: Vec::new(),
pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()],
}
);
let runtime_tx = service
.inner
.sessions
.lock()
.await
.get("1")
.unwrap()
.runtime_tx
.clone();
runtime_tx
.send(RuntimeCommand::TimeoutFired { id: 1 })
.unwrap();
let resumed_response = tokio::time::timeout(
Duration::from_secs(1),
service.wait_to_pending(WaitToPendingRequest {
cell_id: "1".to_string(),
}),
)
.await
.unwrap()
.unwrap();
assert_eq!(
resumed_response,
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: Vec::new(),
pending_tool_call_ids: vec!["tool-3".to_string()],
})
);
let termination = service
.wait(WaitRequest {
cell_id: "1".to_string(),
yield_time_ms: 1,
terminate: true,
})
.await
.unwrap();
assert_eq!(
termination,
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
cell_id: "1".to_string(),
content_items: Vec::new(),
})
);
}
#[tokio::test]
async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() {
let service = CodeModeService::new();
let initial_response = service
.execute_to_pending(ExecuteRequest {
source: r#"
await new Promise((resolve) => setTimeout(resolve, 60_000));
text("after");
await new Promise(() => {});
"#
.to_string(),
yield_time_ms: Some(60_000),
..execute_request("")
})
.await
.unwrap();
assert_eq!(
initial_response,
ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: Vec::new(),
pending_tool_call_ids: Vec::new(),
}
);
let runtime_tx = service
.inner
.sessions
.lock()
.await
.get("1")
.unwrap()
.runtime_tx
.clone();
runtime_tx
.send(RuntimeCommand::TimeoutFired { id: 1 })
.unwrap();
let resumed_response = tokio::time::timeout(
Duration::from_secs(1),
service.wait_to_pending(WaitToPendingRequest {
cell_id: "1".to_string(),
}),
)
.await
.unwrap()
.unwrap();
assert_eq!(
resumed_response,
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "after".to_string(),
}],
pending_tool_call_ids: Vec::new(),
})
);
let termination = service
.wait(WaitRequest {
cell_id: "1".to_string(),
yield_time_ms: 1,
terminate: true,
})
.await
.unwrap();
assert_eq!(
termination,
WaitOutcome::LiveCell(RuntimeResponse::Terminated {
cell_id: "1".to_string(),
content_items: Vec::new(),
})
);
}
#[tokio::test]
async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() {
let service = CodeModeService::new();
let initial_response = service
.execute_to_pending(ExecuteRequest {
source: r#"
await new Promise((resolve) => setTimeout(resolve, 60_000));
text("done");
"#
.to_string(),
yield_time_ms: Some(60_000),
..execute_request("")
})
.await
.unwrap();
assert_eq!(
initial_response,
ExecuteToPendingOutcome::Pending {
cell_id: "1".to_string(),
content_items: Vec::new(),
pending_tool_call_ids: Vec::new(),
}
);
let runtime_tx = service
.inner
.sessions
.lock()
.await
.get("1")
.unwrap()
.runtime_tx
.clone();
runtime_tx
.send(RuntimeCommand::TimeoutFired { id: 1 })
.unwrap();
let resumed_response = tokio::time::timeout(
Duration::from_secs(1),
service.wait_to_pending(WaitToPendingRequest {
cell_id: "1".to_string(),
}),
)
.await
.unwrap()
.unwrap();
assert_eq!(
resumed_response,
WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed(
RuntimeResponse::Result {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "done".to_string(),
}],
stored_values: HashMap::new(),
error_text: None,
}
))
);
}
#[tokio::test]
async fn v8_console_is_not_exposed_on_global_this() {
let service = CodeModeService::new();
@@ -892,13 +1442,14 @@ image({
let (control_tx, control_rx) = mpsc::unbounded_channel();
let (initial_response_tx, initial_response_rx) = oneshot::channel();
let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel();
let (runtime_tx, runtime_terminate_handle) = spawn_runtime(
let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime(
ExecuteRequest {
source: "await new Promise(() => {})".to_string(),
yield_time_ms: None,
..execute_request("")
},
runtime_event_tx,
PendingRuntimeMode::Continue,
)
.unwrap();
@@ -907,12 +1458,14 @@ image({
SessionControlContext {
cell_id: "cell-1".to_string(),
runtime_tx: runtime_tx.clone(),
runtime_control_tx,
pending_mode: PendingRuntimeMode::Continue,
runtime_terminate_handle,
},
event_rx,
control_rx,
initial_response_tx,
/*initial_yield_time_ms*/ 60_000,
SessionResponseSender::Runtime(initial_response_tx),
Some(/*initial_yield_time_ms*/ 60_000),
));
event_tx.send(RuntimeEvent::Started).unwrap();
-1
View File
@@ -63,7 +63,6 @@ pub use crate::provider::Provider;
pub use crate::provider::RetryConfig;
pub use crate::provider::is_azure_responses_provider;
pub use crate::requests::Compression;
pub use crate::sse::stream_from_fixture;
pub use crate::telemetry::SseTelemetry;
pub use crate::telemetry::WebsocketTelemetry;
pub use codex_protocol::protocol::RealtimeAudioFrame;
@@ -5,11 +5,9 @@ use http::HeaderValue;
pub fn build_session_headers(session_id: Option<String>, thread_id: Option<String>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(id) = session_id {
insert_header(&mut headers, "session_id", &id);
insert_header(&mut headers, "session-id", &id);
}
if let Some(id) = thread_id {
insert_header(&mut headers, "thread_id", &id);
insert_header(&mut headers, "thread-id", &id);
}
headers
-1
View File
@@ -3,4 +3,3 @@ pub(crate) mod responses;
pub(crate) use responses::ResponsesStreamEvent;
pub(crate) use responses::process_responses_event;
pub use responses::spawn_response_stream;
pub use responses::stream_from_fixture;
+3 -34
View File
@@ -5,24 +5,19 @@ use crate::rate_limits::parse_all_rate_limits;
use crate::telemetry::SseTelemetry;
use codex_client::ByteStream;
use codex_client::StreamResponse;
use codex_client::TransportError;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::TokenUsage;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use futures::TryStreamExt;
use serde::Deserialize;
use serde_json::Value;
use std::io::BufRead;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio::time::timeout;
use tokio_util::io::ReaderStream;
use tracing::debug;
use tracing::trace;
@@ -31,35 +26,6 @@ const OPENAI_MODEL_HEADER: &str = "openai-model";
const REQUEST_ID_HEADER: &str = "x-request-id";
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
/// Streams SSE events from an on-disk fixture for tests.
pub fn stream_from_fixture(
path: impl AsRef<Path>,
idle_timeout: Duration,
) -> Result<ResponseStream, ApiError> {
let file =
std::fs::File::open(path.as_ref()).map_err(|err| ApiError::Stream(err.to_string()))?;
let mut content = String::new();
for line in std::io::BufReader::new(file).lines() {
let line = line.map_err(|err| ApiError::Stream(err.to_string()))?;
content.push_str(&line);
content.push_str("\n\n");
}
let reader = std::io::Cursor::new(content);
let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string()));
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent, ApiError>>(1600);
tokio::spawn(process_sse(
Box::pin(stream),
tx_event,
idle_timeout,
/*telemetry*/ None,
));
Ok(ResponseStream {
rx_event,
upstream_request_id: None,
})
}
pub fn spawn_response_stream(
stream_response: StreamResponse,
idle_timeout: Duration,
@@ -593,8 +559,10 @@ mod tests {
use assert_matches::assert_matches;
use bytes::Bytes;
use codex_client::StreamResponse;
use codex_client::TransportError;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use futures::TryStreamExt;
use futures::stream;
use http::HeaderMap;
use http::HeaderValue;
@@ -603,6 +571,7 @@ mod tests {
use serde_json::json;
use tokio::sync::mpsc;
use tokio_test::io::Builder as IoBuilder;
use tokio_util::io::ReaderStream;
async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent, ApiError>> {
let mut builder = IoBuilder::new();
-8
View File
@@ -458,18 +458,10 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
assert_eq!(requests.len(), 1);
let req = &requests[0];
assert_eq!(
req.headers.get("session_id").and_then(|v| v.to_str().ok()),
Some("sess_123")
);
assert_eq!(
req.headers.get("session-id").and_then(|v| v.to_str().ok()),
Some("sess_123")
);
assert_eq!(
req.headers.get("thread_id").and_then(|v| v.to_str().ok()),
Some("thread_123")
);
assert_eq!(
req.headers.get("thread-id").and_then(|v| v.to_str().ok()),
Some("thread_123")
+1
View File
@@ -32,6 +32,7 @@ multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_ignored = { workspace = true }
serde_json = { workspace = true }
serde_path_to_error = { workspace = true }
sha2 = { workspace = true }
@@ -87,6 +87,7 @@ pub struct ConfigRequirements {
pub approvals_reviewer: ConstrainedWithSource<ApprovalsReviewer>,
pub permission_profile: ConstrainedWithSource<PermissionProfile>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -120,6 +121,7 @@ impl Default for ConfigRequirements {
Constrained::allow_any(WebSearchMode::Cached),
/*source*/ None,
),
allow_managed_hooks_only: None,
feature_requirements: None,
managed_hooks: None,
mcp_servers: None,
@@ -688,6 +690,7 @@ pub struct ConfigRequirementsToml {
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option<FeatureRequirementsToml>,
pub hooks: Option<ManagedHooksRequirementsToml>,
@@ -736,6 +739,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -772,6 +776,7 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes: _,
remote_sandbox_config: _,
allowed_web_search_modes: _,
allow_managed_hooks_only: _,
feature_requirements: _,
hooks: _,
mcp_servers: _,
@@ -801,6 +806,7 @@ impl ConfigRequirementsWithSources {
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
@@ -828,6 +834,7 @@ impl ConfigRequirementsWithSources {
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
@@ -845,6 +852,7 @@ impl ConfigRequirementsWithSources {
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
feature_requirements: feature_requirements.map(|sourced| sourced.value),
hooks: hooks.map(|sourced| sourced.value),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
@@ -928,6 +936,7 @@ impl ConfigRequirementsToml {
&& self.allowed_sandbox_modes.is_none()
&& self.remote_sandbox_config.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.allow_managed_hooks_only.is_none()
&& self
.feature_requirements
.as_ref()
@@ -965,6 +974,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
@@ -1200,6 +1210,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
approvals_reviewer,
permission_profile,
web_search_mode,
allow_managed_hooks_only,
feature_requirements,
managed_hooks,
mcp_servers,
@@ -1272,6 +1283,7 @@ mod tests {
allowed_sandbox_modes,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
feature_requirements,
hooks,
mcp_servers,
@@ -1292,6 +1304,8 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_managed_hooks_only: allow_managed_hooks_only
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
feature_requirements: feature_requirements
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
@@ -1308,6 +1322,32 @@ mod tests {
}
}
#[test]
fn deserialize_allow_managed_hooks_only() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
allow_managed_hooks_only = true
"#,
)?;
assert_eq!(requirements.allow_managed_hooks_only, Some(true));
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn allow_managed_hooks_only_false_is_still_configured() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
allow_managed_hooks_only = false
"#,
)?;
assert_eq!(requirements.allow_managed_hooks_only, Some(false));
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn merge_unset_fields_copies_every_field_and_sets_sources() {
let mut target = ConfigRequirementsWithSources::default();
@@ -1339,6 +1379,7 @@ mod tests {
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
allow_managed_hooks_only: Some(true),
feature_requirements: Some(feature_requirements.clone()),
hooks: None,
mcp_servers: None,
@@ -1369,6 +1410,10 @@ mod tests {
allowed_web_search_modes,
enforce_source.clone(),
)),
allow_managed_hooks_only: Some(Sourced::new(
/*value*/ true,
enforce_source.clone(),
)),
feature_requirements: Some(Sourced::new(
feature_requirements,
enforce_source.clone(),
@@ -1411,6 +1456,7 @@ mod tests {
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
@@ -1458,6 +1504,7 @@ mod tests {
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
+3
View File
@@ -162,6 +162,9 @@ pub struct ConfigToml {
/// Whether to inject the `<apps_instructions>` developer block.
pub include_apps_instructions: Option<bool>,
/// Whether to inject the `<collaboration_mode>` developer block.
pub include_collaboration_mode_instructions: Option<bool>,
/// Whether to inject the `<environment_context>` user block.
pub include_environment_context: Option<bool>,
+86 -6
View File
@@ -86,6 +86,23 @@ impl std::error::Error for ConfigLoadError {
}
}
#[derive(Clone, Copy)]
pub(crate) enum ConfigDiagnosticSource<'a> {
Path(&'a Path),
#[cfg(any(target_os = "macos", test))]
DisplayName(&'a str),
}
impl ConfigDiagnosticSource<'_> {
pub(crate) fn to_path_buf(self) -> PathBuf {
match self {
ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
#[cfg(any(target_os = "macos", test))]
ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
}
}
}
pub fn io_error_from_config_error(
kind: io::ErrorKind,
error: ConfigError,
@@ -98,21 +115,39 @@ pub fn config_error_from_toml(
path: impl AsRef<Path>,
contents: &str,
err: toml::de::Error,
) -> ConfigError {
config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err)
}
pub(crate) fn config_error_from_toml_for_source(
source: ConfigDiagnosticSource<'_>,
contents: &str,
err: toml::de::Error,
) -> ConfigError {
let range = err
.span()
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
ConfigError::new(path.as_ref().to_path_buf(), range, err.message())
ConfigError::new(source.to_path_buf(), range, err.message())
}
pub fn config_error_from_typed_toml<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
) -> Option<ConfigError> {
config_error_from_typed_toml_for_source::<T>(
ConfigDiagnosticSource::Path(path.as_ref()),
contents,
)
}
fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(
source: ConfigDiagnosticSource<'_>,
contents: &str,
) -> Option<ConfigError> {
let deserializer = match toml::de::Deserializer::parse(contents) {
Ok(deserializer) => deserializer,
Err(err) => return Some(config_error_from_toml(path, contents, err)),
Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)),
};
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
@@ -126,7 +161,7 @@ pub fn config_error_from_typed_toml<T: DeserializeOwned>(
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
path.as_ref().to_path_buf(),
source.to_path_buf(),
range,
toml_err.message(),
))
@@ -205,7 +240,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op
}
}
fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
let start = position_for_offset(contents, span.start);
let end_index = if span.end > span.start {
span.end - 1
@@ -290,7 +325,7 @@ fn position_for_offset(contents: &str, index: usize) -> TextPosition {
}
}
fn default_range() -> TextRange {
pub(crate) fn default_range() -> TextRange {
let position = TextPosition { line: 1, column: 1 };
TextRange {
start: position,
@@ -314,7 +349,10 @@ fn span_for_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usi
}
}
fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usize>> {
pub(crate) fn span_for_config_path(
contents: &str,
path: &SerdePath,
) -> Option<std::ops::Range<usize>> {
if is_features_table_path(path)
&& let Some(span) = span_for_features_value(contents)
{
@@ -323,6 +361,48 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Ra
span_for_path(contents, path)
}
pub(crate) fn span_for_toml_key_path(
contents: &str,
path: &[String],
) -> Option<std::ops::Range<usize>> {
let doc = contents.parse::<Document<String>>().ok()?;
let mut node = TomlNode::Item(doc.as_item());
for (index, segment) in path.iter().enumerate() {
if index + 1 == path.len() {
let key_span = match &node {
TomlNode::Item(item) => item
.as_table_like()
.and_then(|table| table.get_key_value(segment))
.and_then(|(key, _)| key.span()),
TomlNode::Table(table) => {
table.get_key_value(segment).and_then(|(key, _)| key.span())
}
TomlNode::Value(Value::InlineTable(table)) => {
table.get_key_value(segment).and_then(|(key, _)| key.span())
}
_ => None,
};
if key_span.is_some() {
return key_span;
}
}
if let Some(next) = map_child(&node, segment) {
node = next;
continue;
}
let index = segment.parse::<usize>().ok()?;
node = seq_child(&node, index)?;
}
match node {
TomlNode::Item(item) => item.span(),
TomlNode::Table(table) => table.span(),
TomlNode::Value(value) => value.span(),
}
}
fn is_features_table_path(path: &SerdePath) -> bool {
let mut segments = path.iter();
matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")
+3
View File
@@ -21,6 +21,7 @@ mod requirements_exec_policy;
pub mod schema;
mod skills_config;
mod state;
mod strict_config;
mod thread_config;
mod tui_keymap;
pub mod types;
@@ -117,7 +118,9 @@ pub use skills_config::SkillsConfig;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
pub use state::ConfigLoadOptions;
pub use state::LoaderOverrides;
pub use strict_config::config_error_from_ignored_toml_fields;
pub use thread_config::NoopThreadConfigLoader;
pub use thread_config::RemoteThreadConfigLoader;
pub use thread_config::SessionThreadConfig;
+2 -1
View File
@@ -10,13 +10,14 @@ This module is the canonical place to **load and describe Codex configuration la
Exported from `codex_config::loader`:
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `ConfigLayerStack`
- `effective_config() -> toml::Value`
- `origins() -> HashMap<String, ConfigLayerMetadata>`
- `layers_high_to_low() -> Vec<ConfigLayer>`
- `with_user_config(user_config) -> ConfigLayerStack`
- `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata)
- `ConfigLoadOptions` (user-facing load behavior such as strict config validation)
- `LoaderOverrides` (test/override hooks for managed config sources)
- `merge_toml_values(base, overlay)` (public helper used elsewhere)
+56 -12
View File
@@ -2,11 +2,14 @@
use super::macos::ManagedAdminConfigLayer;
#[cfg(target_os = "macos")]
use super::macos::load_managed_admin_config_layer;
use crate::config_toml::ConfigToml;
use crate::diagnostics::config_error_from_toml;
use crate::diagnostics::io_error_from_config_error;
use crate::state::LoaderOverrides;
use crate::strict_config::config_error_from_ignored_toml_value_fields;
use codex_file_system::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use std::io;
use std::path::Path;
use std::path::PathBuf;
@@ -39,6 +42,7 @@ pub(super) async fn load_config_layers_internal(
fs: &dyn ExecutorFileSystem,
codex_home: &Path,
overrides: LoaderOverrides,
strict_config: bool,
) -> io::Result<LoadedConfigLayers> {
#[cfg(target_os = "macos")]
let LoaderOverrides {
@@ -57,19 +61,26 @@ pub(super) async fn load_config_layers_internal(
managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)),
)?;
let managed_config =
read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false)
.await?
.map(|managed_config| MangedConfigFromFile {
managed_config,
file: managed_config_path.clone(),
});
let managed_config = read_config_from_path(
fs,
&managed_config_path,
/*log_missing_as_info*/ false,
strict_config,
)
.await?
.map(|loaded| MangedConfigFromFile {
managed_config: loaded,
file: managed_config_path.clone(),
});
#[cfg(target_os = "macos")]
let managed_preferences =
load_managed_admin_config_layer(managed_preferences_base64.as_deref())
.await?
.map(map_managed_admin_layer);
let managed_preferences = load_managed_admin_config_layer(
managed_preferences_base64.as_deref(),
strict_config,
codex_home,
)
.await?
.map(map_managed_admin_layer);
#[cfg(not(target_os = "macos"))]
let managed_preferences = None;
@@ -93,10 +104,16 @@ pub(super) async fn read_config_from_path(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
log_missing_as_info: bool,
strict_config: bool,
) -> io::Result<Option<TomlValue>> {
match fs.read_file_text(path, /*sandbox*/ None).await {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(value) => Ok(Some(value)),
Ok(value) => {
if strict_config {
validate_config_toml_strictly(path, &contents, &value)?;
}
Ok(Some(value))
}
Err(err) => {
tracing::error!("Failed to parse {}: {err}", path.as_path().display());
let config_error = config_error_from_toml(path.as_path(), &contents, err.clone());
@@ -122,6 +139,33 @@ pub(super) async fn read_config_from_path(
}
}
fn validate_config_toml_strictly(
path: &AbsolutePathBuf,
contents: &str,
value: &TomlValue,
) -> io::Result<()> {
let Some(base_dir) = path.as_path().parent() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Config file {} has no parent directory", path.display()),
));
};
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
path.as_path(),
contents,
value.clone(),
) {
return Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
));
}
Ok(())
}
/// Return the default managed config path.
pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf {
#[cfg(unix)]
+74 -12
View File
@@ -2,13 +2,20 @@ use super::merge_requirements_with_remote_sandbox_config;
use crate::config_requirements::ConfigRequirementsToml;
use crate::config_requirements::ConfigRequirementsWithSources;
use crate::config_requirements::RequirementSource;
use crate::config_toml::ConfigToml;
use crate::diagnostics::ConfigDiagnosticSource;
use crate::diagnostics::config_error_from_toml_for_source;
use crate::diagnostics::io_error_from_config_error;
use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use core_foundation::string::CFStringRef;
use std::ffi::c_void;
use std::io;
use std::path::Path;
use tokio::task;
use toml::Value as TomlValue;
@@ -31,17 +38,20 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
strict_config: bool,
base_dir: &Path,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
if let Some(encoded) = override_base64 {
let trimmed = encoded.trim();
return if trimmed.is_empty() {
Ok(None)
} else {
parse_managed_config_base64(trimmed).map(Some)
parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some)
};
}
match task::spawn_blocking(load_managed_admin_config).await {
let base_dir = base_dir.to_path_buf();
match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await {
Ok(result) => result,
Err(join_err) => {
if join_err.is_cancelled() {
@@ -54,11 +64,14 @@ pub(crate) async fn load_managed_admin_config_layer(
}
}
fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
fn load_managed_admin_config(
strict_config: bool,
base_dir: &Path,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?
.as_deref()
.map(str::trim)
.map(parse_managed_config_base64)
.map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir))
.transpose()
}
@@ -134,24 +147,73 @@ fn load_managed_preference(key_name: &str) -> io::Result<Option<String>> {
Ok(Some(value))
}
fn parse_managed_config_base64(encoded: &str) -> io::Result<ManagedAdminConfigLayer> {
fn parse_managed_config_base64(
encoded: &str,
strict_config: bool,
base_dir: &Path,
) -> io::Result<ManagedAdminConfigLayer> {
let raw_toml = decode_managed_preferences_base64(encoded)?;
match toml::from_str::<TomlValue>(&raw_toml) {
Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer {
let source_name =
format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}");
let parsed = toml::from_str::<TomlValue>(&raw_toml).map_err(|err| {
tracing::error!("Failed to parse managed config TOML: {err}");
if strict_config {
let config_error = config_error_from_toml_for_source(
ConfigDiagnosticSource::DisplayName(&source_name),
&raw_toml,
err.clone(),
);
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
} else {
io::Error::new(io::ErrorKind::InvalidData, err)
}
})?;
validate_managed_config_toml_strictly_if_requested(
strict_config,
&source_name,
&raw_toml,
&parsed,
base_dir,
)?;
match parsed {
TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer {
config: TomlValue::Table(parsed),
raw_toml,
}),
Ok(other) => {
other => {
tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
Err(io::Error::new(
io::ErrorKind::InvalidData,
"managed config root must be a table",
))
}
Err(err) => {
tracing::error!("Failed to parse managed config TOML: {err}");
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
}
}
fn validate_managed_config_toml_strictly_if_requested(
strict_config: bool,
source_name: &str,
raw_toml: &str,
parsed: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
if !strict_config {
return Ok(());
}
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::<
ConfigToml,
>(source_name, raw_toml, parsed.clone())
{
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
} else {
Ok(())
}
}
+213 -16
View File
@@ -21,7 +21,11 @@ use crate::project_root_markers::default_project_root_markers;
use crate::project_root_markers::project_root_markers_from_config;
use crate::state::ConfigLayerEntry;
use crate::state::ConfigLayerStack;
use crate::state::ConfigLoadOptions;
use crate::state::LoaderOverrides;
use crate::strict_config::config_error_from_ignored_toml_value_fields;
use crate::strict_config::ignored_toml_value_field;
use crate::strict_config::unknown_feature_toml_value_field;
use crate::thread_config::ThreadConfigContext;
use crate::thread_config::ThreadConfigLoader;
use codex_app_server_protocol::ConfigLayerSource;
@@ -104,10 +108,14 @@ pub async fn load_config_layers_state(
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
options: impl Into<ConfigLoadOptions>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: &dyn ThreadConfigLoader,
) -> io::Result<ConfigLayerStack> {
let ConfigLoadOptions {
loader_overrides: overrides,
strict_config,
} = options.into();
let ignore_managed_requirements = overrides.ignore_managed_requirements;
let ignore_user_config = overrides.ignore_user_config;
let ignore_user_and_project_exec_policy_rules =
@@ -140,7 +148,8 @@ pub async fn load_config_layers_state(
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers =
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?;
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config)
.await?;
if !ignore_managed_requirements {
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
@@ -168,6 +177,9 @@ pub async fn load_config_layers_state(
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(codex_home);
if strict_config {
validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?;
}
Some(resolve_relative_paths_in_config_toml(
cli_overrides_layer,
base_dir,
@@ -177,16 +189,20 @@ pub async fn load_config_layers_state(
// Include an entry for the "system" config folder, loading its config.toml,
// if it exists.
let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?;
let system_layer =
load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| {
let system_layer = load_config_toml_for_required_layer(
fs,
&system_config_toml_file,
strict_config,
|config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: system_config_toml_file.clone(),
},
config_toml,
)
})
.await?;
},
)
.await?;
layers.push(system_layer);
// Add a layer for $CODEX_HOME/config.toml so folder-derived resources such
@@ -201,7 +217,7 @@ pub async fn load_config_layers_state(
TomlValue::Table(toml::map::Map::new()),
)
} else {
load_config_toml_for_required_layer(fs, &user_file, |config_toml| {
load_config_toml_for_required_layer(fs, &user_file, strict_config, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
@@ -268,6 +284,7 @@ pub async fn load_config_layers_state(
&project_trust_context.project_root,
&project_trust_context,
codex_home,
strict_config,
)
.await?;
layers.extend(project_layers.layers);
@@ -359,15 +376,11 @@ fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigL
async fn load_config_toml_for_required_layer(
fs: &dyn ExecutorFileSystem,
toml_file: &AbsolutePathBuf,
strict_config: bool,
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
) -> io::Result<ConfigLayerEntry> {
let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
@@ -377,6 +390,19 @@ async fn load_config_toml_for_required_layer(
),
)
})?;
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
if strict_config {
validate_config_toml_strictly(
toml_file.as_path(),
&contents,
&config,
config_parent,
)?;
}
resolve_relative_paths_in_config_toml(config, config_parent)
}
Err(e) => {
@@ -397,6 +423,51 @@ async fn load_config_toml_for_required_layer(
Ok(create_entry(toml_value))
}
fn validate_config_toml_strictly(
toml_file: &Path,
contents: &str,
value: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
toml_file,
contents,
value.clone(),
) {
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
} else {
Ok(())
}
}
fn validate_cli_overrides_strictly(
cli_overrides_layer: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(ignored_path) = ignored_toml_value_field::<ConfigToml>(cli_overrides_layer.clone())
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
));
}
if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
));
}
Ok(())
}
/// If available, apply requirements from the platform system
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
@@ -627,6 +698,8 @@ struct ProjectTrustContext {
project_root: AbsolutePathBuf,
project_root_key: String,
project_root_lookup_keys: Vec<String>,
checkout_root: Option<AbsolutePathBuf>,
repo_root: Option<AbsolutePathBuf>,
repo_root_key: Option<String>,
repo_root_lookup_keys: Option<Vec<String>>,
projects_trust: std::collections::HashMap<String, TrustLevel>,
@@ -712,22 +785,36 @@ impl ProjectTrustContext {
)),
}
}
fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let checkout_root = self.checkout_root.as_ref()?;
let repo_root = self.repo_root.as_ref()?;
// Regular checkouts resolve both paths to the same root; linked worktrees do not.
if checkout_root == repo_root {
return None;
}
let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?;
Some(repo_root.join(relative_dir).join(".codex"))
}
}
fn project_layer_entry(
dot_codex_folder: &AbsolutePathBuf,
config: TomlValue,
disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
) -> ConfigLayerEntry {
let source = ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
};
if let Some(reason) = disabled_reason {
let entry = if let Some(reason) = disabled_reason {
ConfigLayerEntry::new_disabled(source, config, reason)
} else {
ConfigLayerEntry::new(source, config)
}
};
entry.with_hooks_config_folder_override(hooks_config_folder_override)
}
fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
@@ -786,6 +873,7 @@ async fn project_trust_context(
.first()
.cloned()
.unwrap_or_else(|| project_trust_key(project_root.as_path()));
let checkout_root = find_git_checkout_root(fs, cwd).await;
let repo_root = resolve_root_git_project_for_trust(fs, cwd).await;
let repo_root_lookup_keys = repo_root
.as_ref()
@@ -803,6 +891,8 @@ async fn project_trust_context(
project_root,
project_root_key,
project_root_lookup_keys,
checkout_root,
repo_root,
repo_root_key,
repo_root_lookup_keys,
projects_trust,
@@ -944,6 +1034,24 @@ async fn find_project_root(
Ok(cwd.clone())
}
async fn find_git_checkout_root(
fs: &dyn ExecutorFileSystem,
cwd: &AbsolutePathBuf,
) -> Option<AbsolutePathBuf> {
let base = match fs.get_metadata(cwd, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_directory => cwd.clone(),
_ => cwd.parent()?,
};
for dir in base.ancestors() {
let dot_git = dir.join(".git");
if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() {
return Some(dir);
}
}
None
}
struct LoadedProjectLayers {
layers: Vec<ConfigLayerEntry>,
startup_warnings: Vec<String>,
@@ -961,6 +1069,7 @@ async fn load_project_layers(
project_root: &AbsolutePathBuf,
trust_context: &ProjectTrustContext,
codex_home: &Path,
strict_config: bool,
) -> io::Result<LoadedProjectLayers> {
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
let codex_home_normalized =
@@ -995,6 +1104,7 @@ async fn load_project_layers(
let decision = trust_context.decision_for_dir(&dir);
let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir);
let dot_codex_normalized =
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
@@ -1019,21 +1129,42 @@ async fn load_project_layers(
&dot_codex_abs,
TomlValue::Table(toml::map::Map::new()),
disabled_reason.clone(),
hooks_config_folder_override.clone(),
));
continue;
}
};
let mut config = config;
if disabled_reason.is_none() && strict_config {
validate_config_toml_strictly(
config_file.as_path(),
&contents,
&config,
dot_codex_abs.as_path(),
)?;
}
let ignored_project_config_keys = sanitize_project_config(&mut config);
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
let config = merge_root_checkout_project_hooks(
fs,
config,
hooks_config_folder_override.as_ref(),
decision.is_trusted(),
)
.await?;
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
startup_warnings.push(project_ignored_config_keys_warning(
&dot_codex_abs,
&ignored_project_config_keys,
));
}
let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone());
let entry = project_layer_entry(
&dot_codex_abs,
config,
disabled_reason.clone(),
hooks_config_folder_override.clone(),
);
layers.push(entry);
}
Err(err) => {
@@ -1041,10 +1172,18 @@ async fn load_project_layers(
// If there is no config.toml file, record an empty entry
// for this project layer, as this may still have subfolders
// that are significant in the overall ConfigLayerStack.
let config = merge_root_checkout_project_hooks(
fs,
TomlValue::Table(toml::map::Map::new()),
hooks_config_folder_override.as_ref(),
decision.is_trusted(),
)
.await?;
layers.push(project_layer_entry(
&dot_codex_abs,
TomlValue::Table(toml::map::Map::new()),
config,
disabled_reason,
hooks_config_folder_override,
));
} else {
let config_file_display = config_file.as_path().display();
@@ -1062,6 +1201,64 @@ async fn load_project_layers(
startup_warnings,
})
}
/// For linked worktrees, preserve ordinary worktree-local project config while
/// replacing only hook declarations with the matching root-checkout layer.
async fn merge_root_checkout_project_hooks(
fs: &dyn ExecutorFileSystem,
mut config: TomlValue,
hooks_config_folder_override: Option<&AbsolutePathBuf>,
is_trusted: bool,
) -> io::Result<TomlValue> {
let Some(hooks_config_folder) = hooks_config_folder_override else {
return Ok(config);
};
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
let root_config = match fs
.read_file_text(&hooks_config_file, /*sandbox*/ None)
.await
{
Ok(contents) => {
let parsed: TomlValue = match toml::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
if is_trusted {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing project hooks config file {}: {err}",
hooks_config_file.as_path().display()
),
));
}
TomlValue::Table(toml::map::Map::new())
}
};
resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())?
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
TomlValue::Table(toml::map::Map::new())
}
Err(err) => {
return Err(io::Error::new(
err.kind(),
format!(
"Failed to read project hooks config file {}: {err}",
hooks_config_file.as_path().display()
),
));
}
};
let Some(config_table) = config.as_table_mut() else {
return Ok(config);
};
config_table.remove("hooks");
if let Some(hooks) = root_config.get("hooks") {
config_table.insert("hooks".to_string(), hooks.clone());
}
Ok(config)
}
/// The legacy mechanism for specifying admin-enforced configuration is to read
/// from a file like `/etc/codex/managed_config.toml` that has the same
/// structure as `config.toml` where fields like `approval_policy` can specify
+1
View File
@@ -57,6 +57,7 @@ pub struct ConfigProfile {
pub include_apply_patch_tool: Option<bool>,
pub include_permissions_instructions: Option<bool>,
pub include_apps_instructions: Option<bool>,
pub include_collaboration_mode_instructions: Option<bool>,
pub include_environment_context: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
+40
View File
@@ -14,6 +14,22 @@ use std::collections::HashMap;
use std::path::PathBuf;
use toml::Value as TomlValue;
/// User-facing config loading behavior that is not part of the config document.
#[derive(Debug, Default, Clone)]
pub struct ConfigLoadOptions {
pub loader_overrides: LoaderOverrides,
pub strict_config: bool,
}
impl From<LoaderOverrides> for ConfigLoadOptions {
fn from(loader_overrides: LoaderOverrides) -> Self {
Self {
loader_overrides,
strict_config: false,
}
}
}
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {
@@ -66,6 +82,7 @@ pub struct ConfigLayerEntry {
pub raw_toml: Option<String>,
pub version: String,
pub disabled_reason: Option<String>,
hooks_config_folder_override: Option<AbsolutePathBuf>,
}
impl ConfigLayerEntry {
@@ -77,6 +94,7 @@ impl ConfigLayerEntry {
raw_toml: None,
version,
disabled_reason: None,
hooks_config_folder_override: None,
}
}
@@ -88,6 +106,7 @@ impl ConfigLayerEntry {
raw_toml: Some(raw_toml),
version,
disabled_reason: None,
hooks_config_folder_override: None,
}
}
@@ -103,6 +122,7 @@ impl ConfigLayerEntry {
raw_toml: None,
version,
disabled_reason: Some(disabled_reason.into()),
hooks_config_folder_override: None,
}
}
@@ -114,6 +134,14 @@ impl ConfigLayerEntry {
self.raw_toml.as_deref()
}
pub(crate) fn with_hooks_config_folder_override(
mut self,
hooks_config_folder_override: Option<AbsolutePathBuf>,
) -> Self {
self.hooks_config_folder_override = hooks_config_folder_override;
self
}
pub fn metadata(&self) -> ConfigLayerMetadata {
ConfigLayerMetadata {
name: self.name.clone(),
@@ -142,6 +170,18 @@ impl ConfigLayerEntry {
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
}
}
/// Returns the `.codex/` folder that should be used for hook declarations.
///
/// Project layers normally use their own config folder. Linked Git worktrees
/// can instead point hook discovery at the matching folder from the root
/// checkout while the rest of the project config still comes from the
/// worktree.
pub fn hooks_config_folder(&self) -> Option<AbsolutePathBuf> {
self.hooks_config_folder_override
.clone()
.or_else(|| self.config_folder())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+201
View File
@@ -0,0 +1,201 @@
//! Strict config validation built on top of serde's ignored-field tracking.
use crate::diagnostics::ConfigDiagnosticSource;
use crate::diagnostics::ConfigError;
use crate::diagnostics::config_error_from_toml_for_source;
use crate::diagnostics::default_range;
use crate::diagnostics::span_for_config_path;
use crate::diagnostics::span_for_toml_key_path;
use crate::diagnostics::text_range_from_span;
use codex_features::is_known_feature_key;
use serde::de::DeserializeOwned;
use std::path::Path;
use toml::Value as TomlValue;
pub fn config_error_from_ignored_toml_fields<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
) -> Option<ConfigError> {
let source = ConfigDiagnosticSource::Path(path.as_ref());
match toml::from_str::<TomlValue>(contents) {
Ok(value) => {
config_error_from_ignored_toml_value_fields_for_source::<T>(source, contents, value)
}
Err(err) => Some(config_error_from_toml_for_source(source, contents, err)),
}
}
pub(crate) fn config_error_from_ignored_toml_value_fields<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
value: TomlValue,
) -> Option<ConfigError> {
config_error_from_ignored_toml_value_fields_for_source::<T>(
ConfigDiagnosticSource::Path(path.as_ref()),
contents,
value,
)
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name<T: DeserializeOwned>(
source_name: &str,
contents: &str,
value: TomlValue,
) -> Option<ConfigError> {
config_error_from_ignored_toml_value_fields_for_source::<T>(
ConfigDiagnosticSource::DisplayName(source_name),
contents,
value,
)
}
fn config_error_from_ignored_toml_value_fields_for_source<T: DeserializeOwned>(
source: ConfigDiagnosticSource<'_>,
contents: &str,
value: TomlValue,
) -> Option<ConfigError> {
let unknown_feature_paths = unknown_feature_toml_value_path(&value);
let mut ignored_paths = Vec::new();
let mut ignored_callback = |ignored_path: serde_ignored::Path<'_>| {
let path_segments = ignored_path_segments(&ignored_path);
if !path_segments.is_empty() {
ignored_paths.push(path_segments);
}
};
let deserializer = serde_ignored::Deserializer::new(value, &mut ignored_callback);
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
match result {
Ok(_) => unknown_field_error_from_paths(source, contents, ignored_paths)
.or_else(|| unknown_field_error_from_paths(source, contents, unknown_feature_paths)),
Err(err) => {
let path_hint = err.path().clone();
let toml_err = err.into_inner();
let range = span_for_config_path(contents, &path_hint)
.or_else(|| toml_err.span())
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
source.to_path_buf(),
range,
toml_err.message(),
))
}
}
}
pub(crate) fn ignored_toml_value_field<T: DeserializeOwned>(value: TomlValue) -> Option<String> {
let mut ignored_paths = Vec::new();
let result: Result<T, _> = serde_ignored::deserialize(value, |ignored_path| {
let path_segments = ignored_path_segments(&ignored_path);
if !path_segments.is_empty() {
ignored_paths.push(path_segments);
}
});
if result.is_err() {
return None;
}
ignored_paths
.into_iter()
.next()
.map(|path_segments| path_segments.join("."))
}
pub(crate) fn unknown_feature_toml_value_field(value: &TomlValue) -> Option<String> {
unknown_feature_toml_value_path(value)
.into_iter()
.next()
.map(|path_segments| path_segments.join("."))
}
fn unknown_field_error_from_paths(
source: ConfigDiagnosticSource<'_>,
contents: &str,
ignored_paths: Vec<Vec<String>>,
) -> Option<ConfigError> {
let path_segments = ignored_paths.into_iter().next()?;
let ignored_path = path_segments.join(".");
let range = span_for_toml_key_path(contents, &path_segments)
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
source.to_path_buf(),
range,
format!("unknown configuration field `{ignored_path}`"),
))
}
fn unknown_feature_toml_value_path(value: &TomlValue) -> Vec<Vec<String>> {
let Some(root) = value.as_table() else {
return Vec::new();
};
let mut paths = Vec::new();
push_unknown_feature_paths(&mut paths, &["features"], root.get("features"));
if let Some(profiles) = root.get("profiles").and_then(TomlValue::as_table) {
for (profile_name, profile) in profiles {
let prefix = ["profiles", profile_name.as_str(), "features"];
let features = profile
.as_table()
.and_then(|profile| profile.get("features"));
push_unknown_feature_paths(&mut paths, &prefix, features);
}
}
paths
}
fn push_unknown_feature_paths(
paths: &mut Vec<Vec<String>>,
prefix: &[&str],
features: Option<&TomlValue>,
) {
let Some(features) = features.and_then(TomlValue::as_table) else {
return;
};
for feature_key in features
.keys()
.map(String::as_str)
.filter(|key| !is_known_feature_key(key))
{
let mut path = prefix
.iter()
.map(|segment| (*segment).to_string())
.collect::<Vec<_>>();
path.push(feature_key.to_string());
paths.push(path);
}
}
fn ignored_path_segments(path: &serde_ignored::Path<'_>) -> Vec<String> {
let mut segments = Vec::new();
push_ignored_path_segments(path, &mut segments);
segments
}
fn push_ignored_path_segments(path: &serde_ignored::Path<'_>, segments: &mut Vec<String>) {
match path {
serde_ignored::Path::Root => {}
serde_ignored::Path::Seq { parent, index } => {
push_ignored_path_segments(parent, segments);
segments.push(index.to_string());
}
serde_ignored::Path::Map { parent, key } => {
push_ignored_path_segments(parent, segments);
segments.push(key.clone());
}
serde_ignored::Path::Some { parent }
| serde_ignored::Path::NewtypeStruct { parent }
| serde_ignored::Path::NewtypeVariant { parent } => {
push_ignored_path_segments(parent, segments);
}
}
}
#[cfg(test)]
#[path = "strict_config_tests.rs"]
mod tests;
+112
View File
@@ -0,0 +1,112 @@
use super::*;
use crate::config_toml::ConfigToml;
use crate::diagnostics::TextPosition;
use crate::diagnostics::TextRange;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn ignored_toml_field_errors_accept_non_file_source_names() {
let source_name = "com.openai.codex:config_toml_base64";
let contents = r#"
model = "gpt-5"
unknown_key = true"#;
let value = toml::from_str::<TomlValue>(contents).expect("valid TOML");
let error = config_error_from_ignored_toml_value_fields_for_source_name::<ConfigToml>(
source_name,
contents,
value,
)
.expect("unknown field error");
assert_eq!(
error,
ConfigError::new(
PathBuf::from(source_name),
TextRange {
start: TextPosition { line: 3, column: 1 },
end: TextPosition {
line: 3,
column: 11,
},
},
"unknown configuration field `unknown_key`",
)
);
}
#[test]
fn type_errors_take_precedence_over_ignored_fields() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
model_context_window = "wide"
unknown_key = true"#;
let error =
config_error_from_ignored_toml_fields::<ConfigToml>(path, contents).expect("type error");
assert_eq!(
error,
ConfigError::new(
path.to_path_buf(),
TextRange {
start: TextPosition {
line: 2,
column: 24,
},
end: TextPosition {
line: 2,
column: 29,
},
},
"invalid type: string \"wide\", expected i64",
)
);
}
#[test]
fn strict_config_rejects_unknown_feature_key() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
[features]
foo = true"#;
let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents)
.expect("unknown feature error");
assert_eq!(
error,
ConfigError::new(
path.to_path_buf(),
TextRange {
start: TextPosition { line: 3, column: 1 },
end: TextPosition { line: 3, column: 3 },
},
"unknown configuration field `features.foo`",
)
);
}
#[test]
fn strict_config_rejects_unknown_profile_feature_key() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
[profiles.work.features]
foo = true"#;
let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents)
.expect("unknown feature error");
assert_eq!(
error,
ConfigError::new(
path.to_path_buf(),
TextRange {
start: TextPosition { line: 3, column: 1 },
end: TextPosition { line: 3, column: 3 },
},
"unknown configuration field `profiles.work.features.foo`",
)
);
}
+12
View File
@@ -306,6 +306,18 @@ pub struct TuiListKeymap {
pub move_up: Option<KeybindingsSpec>,
/// Move list selection down.
pub move_down: Option<KeybindingsSpec>,
/// Move horizontally left in list pickers that support horizontal actions.
pub move_left: Option<KeybindingsSpec>,
/// Move horizontally right in list pickers that support horizontal actions.
pub move_right: Option<KeybindingsSpec>,
/// Move list selection up by one page.
pub page_up: Option<KeybindingsSpec>,
/// Move list selection down by one page.
pub page_down: Option<KeybindingsSpec>,
/// Jump to the first list item.
pub jump_top: Option<KeybindingsSpec>,
/// Jump to the last list item.
pub jump_bottom: Option<KeybindingsSpec>,
/// Accept current selection.
pub accept: Option<KeybindingsSpec>,
/// Cancel and close selection view.
+3 -7
View File
@@ -669,12 +669,9 @@ pub struct Tui {
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
/// - `always`: Always use alternate screen (original behavior).
/// - `auto` (default): Use alternate screen.
/// - `always`: Always use alternate screen.
/// - `never`: Never use alternate screen (inline mode only, preserves scrollback).
///
/// Using alternate screen provides a cleaner fullscreen experience but prevents
/// scrollback in terminal multiplexers like Zellij that follow the xterm spec.
#[serde(default)]
pub alternate_screen: AltScreenMode,
@@ -896,8 +893,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}
}
/// Policy for building the `env` when spawning a process via either the
/// `shell` or `local_shell` tool.
/// Policy for building the `env` when spawning a process via shell-like tools.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ShellEnvironmentPolicyToml {
+11
View File
@@ -222,6 +222,7 @@ pub struct PluginReadOutcome {
pub struct PluginDetail {
pub id: String,
pub name: String,
pub local_version: Option<String>,
pub description: Option<String>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
@@ -260,6 +261,7 @@ pub struct ConfiguredMarketplace {
pub struct ConfiguredMarketplacePlugin {
pub id: String,
pub name: String,
pub local_version: Option<String>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
@@ -1199,6 +1201,7 @@ impl PluginsManager {
let installed = installed_plugins.contains(&plugin_key);
let enabled = enabled_plugins.contains(&plugin_key);
let mut interface = plugin.interface;
let mut local_version = plugin.local_version;
if installed
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
&& let Ok(plugin_id) =
@@ -1206,6 +1209,7 @@ impl PluginsManager {
&& let Some(plugin_root) = self.store.active_plugin_root(&plugin_id)
&& let Some(manifest) = load_plugin_manifest(plugin_root.as_path())
{
local_version = manifest.version.clone();
let marketplace_category = interface
.as_ref()
.and_then(|interface| interface.category.clone());
@@ -1223,6 +1227,7 @@ impl PluginsManager {
installed,
enabled,
name: plugin.name,
local_version,
source: plugin.source,
policy: plugin.policy,
keywords: plugin.keywords,
@@ -1273,6 +1278,10 @@ impl PluginsManager {
ConfiguredMarketplacePlugin {
id: plugin_key.clone(),
name: plugin.plugin_id.plugin_name,
local_version: plugin
.manifest
.as_ref()
.and_then(|manifest| manifest.version.clone()),
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
@@ -1319,6 +1328,7 @@ impl PluginsManager {
return Ok(PluginDetail {
id: plugin_key,
name: plugin.name,
local_version: None,
description: Some(description),
source: plugin.source,
policy: plugin.policy,
@@ -1411,6 +1421,7 @@ impl PluginsManager {
Ok(PluginDetail {
id: plugin.id,
name: plugin.name,
local_version: manifest.version.clone(),
description,
source: plugin.source,
policy: plugin.policy,
@@ -1545,6 +1545,7 @@ enabled = false
ConfiguredMarketplacePlugin {
id: "enabled-plugin@debug".to_string(),
name: "enabled-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
@@ -1562,6 +1563,7 @@ enabled = false
ConfiguredMarketplacePlugin {
id: "disabled-plugin@debug".to_string(),
name: "disabled-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
.unwrap(),
@@ -1682,6 +1684,7 @@ plugins = true
vec![ConfiguredMarketplacePlugin {
id: "default-plugin@debug".to_string(),
name: "default-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(),
},
@@ -2113,6 +2116,7 @@ enabled = true
vec![ConfiguredMarketplacePlugin {
id: "toolkit@debug".to_string(),
name: "toolkit".to_string(),
local_version: None,
source: MarketplacePluginSource::Git {
url: missing_remote_repo_url,
path: Some("plugins/toolkit".to_string()),
@@ -2229,6 +2233,7 @@ plugins = true
plugins: vec![ConfiguredMarketplacePlugin {
id: "linear@openai-curated".to_string(),
name: "linear".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
},
@@ -2523,6 +2528,7 @@ enabled = false
vec![ConfiguredMarketplacePlugin {
id: "dup-plugin@debug".to_string(),
name: "dup-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
@@ -2553,6 +2559,7 @@ enabled = false
vec![ConfiguredMarketplacePlugin {
id: "b-only-plugin@debug".to_string(),
name: "b-only-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
@@ -2637,6 +2644,7 @@ enabled = true
plugins: vec![ConfiguredMarketplacePlugin {
id: "sample-plugin@debug".to_string(),
name: "sample-plugin".to_string(),
local_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
},

Some files were not shown because too many files have changed in this diff Show More