mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
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:
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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\":{}}",
|
||||
|
||||
Generated
+53
-22
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+98
@@ -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"
|
||||
},
|
||||
|
||||
+98
@@ -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"
|
||||
},
|
||||
|
||||
+6
@@ -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"
|
||||
}
|
||||
+45
@@ -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};
|
||||
|
||||
+5
@@ -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, };
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 +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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
@@ -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`",
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user